Version badge: was hardcoded '1.0.8' — now single-sourced from frontend/package.json (1.0.0) via Vite define __APP_VERSION__, so it auto-updates on release. Sidebar agent footer: removed the FABRICATED 'asgard-01' host name and the fake 'Agent v1.0.8' line — now shows real server.connection data, or an honest 'No host agent connected' empty state when nothing is deployed (the operator's actual state). Renamed 'Companion agent' -> 'Corrosion host agent' across the UI (ServerView/SetupWizard/Dashboard/Plugins), the binary names (corrosion-host-agent-<os>-<arch>) + CDN path (/host-agent/), the Go Makefile build output, and the Gitea CI workflow — frontend download links and CI output now match. Marketing hero mock host names neutralized (asgard-01 -> rust-host/dune-host/conan-host). DB column names (companion_last_seen) left intact. Build green; zero 'asgard'/'1.0.8' remain in frontend/src. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
556 lines
21 KiB
Vue
556 lines
21 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* DashboardView — Single-server cockpit wired entirely to real data.
|
|
*
|
|
* Architecture:
|
|
* - useServerStore → connection + config + live stats (WebSocket updateStats)
|
|
* - useApi → /analytics/timeseries for 24h player history (PlayersChart)
|
|
* - useGameProfile → per-game labels/terminology (defaults to 'rust' today)
|
|
* - useWebSocket → subscribes to console_output and server_stats events
|
|
*
|
|
* Empty states:
|
|
* - No connection record → "No server connected" EmptyState with CTA to /server
|
|
* - Connection exists but stats absent → meters show '—', chart shows awaiting telemetry
|
|
* - No upcoming wipe schedules → honest empty state in the wipes panel
|
|
*
|
|
* No fabricated data anywhere in this file.
|
|
* The fleet/multi-server view has been removed — the current backend is
|
|
* single-server-per-license. When the backend supports multiple servers per
|
|
* license, restore a fleet tab wired to real data.
|
|
*/
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useServerStore } from '@/stores/server'
|
|
import { useWipeStore } from '@/stores/wipe'
|
|
import { useApi } from '@/composables/useApi'
|
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
|
import { useGameProfile } from '@/config/gameProfiles'
|
|
import { useThemeGame } from '@/composables/useThemeGame'
|
|
import Panel from '@/components/ds/data/Panel.vue'
|
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
|
import ConsoleLineDS from '@/components/ds/data/ConsoleLine.vue'
|
|
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
|
|
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
|
|
import Badge from '@/components/ds/core/Badge.vue'
|
|
import Button from '@/components/ds/core/Button.vue'
|
|
import Input from '@/components/ds/forms/Input.vue'
|
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
|
import type { TimeseriesData, WipeSchedule } from '@/types'
|
|
import { safeDate } from '@/utils/formatters'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stores / composables
|
|
// ---------------------------------------------------------------------------
|
|
const server = useServerStore()
|
|
const wipeStore = useWipeStore()
|
|
const router = useRouter()
|
|
const api = useApi()
|
|
const { activeGame } = useThemeGame()
|
|
|
|
// Profile follows the GameSwitcher selection. 'all' falls back to rust (neutral house skin).
|
|
// When the backend adds a `game` field on licenses, swap activeGame for server.config?.game.
|
|
const profile = computed(() => {
|
|
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
|
|
return useGameProfile(game)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Derived server state — all real, no fallbacks to fabricated values
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const hasConnection = computed(() => server.connection !== null)
|
|
const isConnected = computed(() => server.connection?.connection_status === 'connected')
|
|
|
|
const soloName = computed(() => server.config?.server_name ?? null)
|
|
|
|
const soloPlayers = computed(() => server.stats?.player_count ?? null)
|
|
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? null)
|
|
const soloFps = computed(() => server.stats?.fps ?? null)
|
|
|
|
// Memory: store gives memory_usage_mb; max must come from agent telemetry.
|
|
// We do NOT hard-code a "representative" max — show raw MB and no percentage
|
|
// until the agent reports a known max.
|
|
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? null)
|
|
const soloRamPct = computed(() => {
|
|
// ServerStats has no ram_max field — we cannot compute a real percentage.
|
|
// Return null; ResourceMeter and StatCard will show '—'.
|
|
return null
|
|
})
|
|
const soloRamSub = computed(() => {
|
|
const mb = soloRamMb.value
|
|
if (mb === null) return null
|
|
return `${(mb / 1024).toFixed(1)} GB used`
|
|
})
|
|
|
|
// CPU: not in ServerStats today. Show null — never fabricate.
|
|
const soloCpu = computed(() => null as number | null)
|
|
|
|
const soloStatus = computed<'online' | 'offline' | 'starting'>(() => {
|
|
const cs = server.connection?.connection_status
|
|
if (cs === 'connected') return 'online'
|
|
if (cs === 'degraded') return 'starting'
|
|
return 'offline'
|
|
})
|
|
const soloStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
|
|
if (soloStatus.value === 'online') return 'online'
|
|
if (soloStatus.value === 'starting') return 'warn'
|
|
return 'offline'
|
|
})
|
|
const soloStatusLabel = computed(() => {
|
|
if (soloStatus.value === 'online') return 'Online'
|
|
if (soloStatus.value === 'starting') return 'Degraded'
|
|
return 'Offline'
|
|
})
|
|
|
|
const soloIp = computed(() => {
|
|
const ip = server.connection?.server_ip
|
|
const port = server.connection?.game_port ?? server.connection?.server_port
|
|
if (ip && port) return `${ip}:${port}`
|
|
if (ip) return ip
|
|
return null
|
|
})
|
|
|
|
const soloUptime = computed(() => {
|
|
const sec = server.stats?.uptime_seconds ?? 0
|
|
if (sec === 0) return null
|
|
const d = Math.floor(sec / 86400)
|
|
const h = Math.floor((sec % 86400) / 3600)
|
|
if (d > 0) return `${d}d ${h}h`
|
|
return `${h}h`
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Players chart — real 24h timeseries from /analytics/timeseries
|
|
// ---------------------------------------------------------------------------
|
|
const chartData = ref<number[] | null>(null)
|
|
const chartLoading = ref(false)
|
|
|
|
async function loadChartData() {
|
|
chartLoading.value = true
|
|
try {
|
|
const ts = await api.get<TimeseriesData>('/analytics/timeseries?range=24&granularity=hourly')
|
|
chartData.value = ts.player_count.length > 0 ? ts.player_count : null
|
|
} catch {
|
|
// API unavailable or no data yet — chart will show "awaiting telemetry"
|
|
chartData.value = null
|
|
} finally {
|
|
chartLoading.value = false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wipe schedules — real data from wipeStore
|
|
// ---------------------------------------------------------------------------
|
|
const nextWipe = computed<WipeSchedule | null>(() => {
|
|
const schedules = wipeStore.schedules.filter((s) => s.is_active && s.next_scheduled_run)
|
|
if (schedules.length === 0) return null
|
|
return schedules.slice().sort((a, b) => {
|
|
const at = a.next_scheduled_run ? new Date(a.next_scheduled_run).getTime() : Infinity
|
|
const bt = b.next_scheduled_run ? new Date(b.next_scheduled_run).getTime() : Infinity
|
|
return at - bt
|
|
})[0] ?? null
|
|
})
|
|
|
|
const nextWipeLabel = computed(() => {
|
|
const w = nextWipe.value
|
|
if (!w?.next_scheduled_run) return null
|
|
return safeDate(w.next_scheduled_run)
|
|
})
|
|
|
|
const nextWipeType = computed(() => {
|
|
const w = nextWipe.value
|
|
if (!w) return null
|
|
const t = w.wipe_type
|
|
if (t === 'full') return `Full ${profile.value.terminology.reset}`
|
|
if (t === 'blueprint') return 'Blueprint wipe'
|
|
return `Map ${profile.value.terminology.reset}`
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Console lines — real WebSocket events only
|
|
// ---------------------------------------------------------------------------
|
|
interface ConsoleLine {
|
|
time: string
|
|
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
|
who?: string
|
|
msg: string
|
|
}
|
|
|
|
const consoleLines = ref<ConsoleLine[]>([])
|
|
const MAX_CONSOLE_LINES = 100
|
|
|
|
function now(): string {
|
|
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
|
}
|
|
|
|
function handleWsMessage(msg: WebSocketMessage) {
|
|
if (msg.type !== 'event') return
|
|
|
|
// Live server stats
|
|
if (msg.event === 'server_stats' && msg.data) {
|
|
server.updateStats(msg.data)
|
|
return
|
|
}
|
|
|
|
// Console output lines
|
|
if (msg.event === 'console_output') {
|
|
const text = msg.data?.line ?? msg.data?.output ?? msg.raw ?? ''
|
|
if (!text) return
|
|
consoleLines.value.push({
|
|
time: now(),
|
|
level: 'info',
|
|
msg: String(text),
|
|
})
|
|
if (consoleLines.value.length > MAX_CONSOLE_LINES) {
|
|
consoleLines.value.splice(0, consoleLines.value.length - MAX_CONSOLE_LINES)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Console input
|
|
// ---------------------------------------------------------------------------
|
|
const consoleInput = ref('')
|
|
|
|
function sendConsoleCommand() {
|
|
const cmd = consoleInput.value.trim()
|
|
if (!cmd) return
|
|
consoleLines.value.push({ time: now(), level: 'cmd', who: 'admin', msg: cmd })
|
|
server.sendCommand(cmd).catch(() => {})
|
|
consoleInput.value = ''
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
let unsubscribe: (() => void) | null = null
|
|
|
|
onMounted(async () => {
|
|
await server.fetchServer()
|
|
await wipeStore.fetchSchedules()
|
|
await loadChartData()
|
|
|
|
const ws = useWebSocket()
|
|
unsubscribe = ws.subscribe(handleWsMessage)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
unsubscribe?.()
|
|
})
|
|
|
|
// Navigation helpers
|
|
function navConsole() { router.push('/console') }
|
|
function navWipes() { router.push('/wipes') }
|
|
function navServer() { router.push('/server') }
|
|
</script>
|
|
|
|
<template>
|
|
<div class="dash">
|
|
|
|
<!-- ===== NO CONNECTION: honest empty state ===== -->
|
|
<template v-if="!server.isLoading && !hasConnection">
|
|
<div class="page__head">
|
|
<div>
|
|
<div class="t-eyebrow">Dashboard</div>
|
|
<h1 class="page__title">Server cockpit</h1>
|
|
</div>
|
|
</div>
|
|
<Panel>
|
|
<EmptyState
|
|
icon="server"
|
|
title="No server connected"
|
|
description="Install the Corrosion host agent on your host machine to begin managing your server from Corrosion."
|
|
>
|
|
<template #action>
|
|
<Button icon="server" @click="navServer">Set up server</Button>
|
|
</template>
|
|
</EmptyState>
|
|
</Panel>
|
|
</template>
|
|
|
|
<!-- ===== SERVER COCKPIT ===== -->
|
|
<template v-else-if="hasConnection">
|
|
|
|
<!-- Page head -->
|
|
<div class="page__head">
|
|
<div class="solo-id">
|
|
<div class="solo-id__chip">
|
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
|
|
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="solo-id__name">
|
|
{{ soloName ?? 'Server' }}
|
|
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
|
|
</div>
|
|
<div class="solo-id__meta">
|
|
<template v-if="soloIp">{{ soloIp }}</template>
|
|
<template v-else>No IP registered</template>
|
|
<template v-if="soloUptime"> · up {{ soloUptime }}</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="page__actions">
|
|
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
|
|
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
|
|
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPIs — game profile drives stat labels; null values show '—' -->
|
|
<div class="dash__kpis">
|
|
<StatCard
|
|
icon="users"
|
|
:label="(profile.statFields[0] ?? 'Players') + ' online'"
|
|
:value="soloPlayers !== null ? String(soloPlayers) : '—'"
|
|
:unit="soloMaxPlayers !== null ? '/' + soloMaxPlayers : ''"
|
|
note="live via agent"
|
|
/>
|
|
<StatCard
|
|
icon="cpu"
|
|
label="CPU"
|
|
:value="soloCpu !== null ? String(soloCpu) : '—'"
|
|
:unit="soloCpu !== null ? '%' : ''"
|
|
note="agent telemetry"
|
|
/>
|
|
<StatCard
|
|
icon="memory-stick"
|
|
label="Memory"
|
|
:value="soloRamMb !== null ? (soloRamMb / 1024).toFixed(1) : '—'"
|
|
:unit="soloRamMb !== null ? 'GB' : ''"
|
|
:note="soloRamSub ?? 'agent telemetry'"
|
|
/>
|
|
<StatCard
|
|
icon="gauge"
|
|
label="Server FPS"
|
|
:value="soloFps !== null ? String(soloFps) : '—'"
|
|
:unit="soloFps !== null ? 'fps' : ''"
|
|
note="live via agent"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Main grid -->
|
|
<div class="dash__grid">
|
|
|
|
<!-- Left column -->
|
|
<div class="dash__col">
|
|
|
|
<!-- Players chart — real 24h data or honest empty state -->
|
|
<Panel
|
|
title="Players online"
|
|
:subtitle="(soloName ?? 'Server') + ' · last 24 hours'"
|
|
>
|
|
<div v-if="chartLoading" class="chart-loading">Loading telemetry…</div>
|
|
<PlayersChart
|
|
v-else
|
|
:height="196"
|
|
:max="soloMaxPlayers ?? 200"
|
|
:data="chartData ?? undefined"
|
|
/>
|
|
</Panel>
|
|
|
|
<!-- Console — real WebSocket lines only -->
|
|
<Panel :flush-body="true" title="Console">
|
|
<template #actions>
|
|
<Badge
|
|
:tone="isConnected ? 'online' : 'offline'"
|
|
:dot="true"
|
|
:pulse="isConnected"
|
|
>{{ isConnected ? 'Live' : 'Disconnected' }}</Badge>
|
|
</template>
|
|
|
|
<div class="feed feed--solo">
|
|
<template v-if="consoleLines.length > 0">
|
|
<ConsoleLineDS
|
|
v-for="(line, i) in consoleLines"
|
|
:key="i"
|
|
:time="line.time"
|
|
:level="line.level"
|
|
:who="line.who"
|
|
>{{ line.msg }}</ConsoleLineDS>
|
|
</template>
|
|
<div v-else class="feed__empty">
|
|
<span v-if="isConnected">Waiting for output — try sending a command below</span>
|
|
<span v-else>Console offline — server is not connected</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="console-bar">
|
|
<span class="console-bar__prompt">></span>
|
|
<Input
|
|
v-model="consoleInput"
|
|
:mono="true"
|
|
size="sm"
|
|
placeholder="say, kick, ban, oxide.reload …"
|
|
:disabled="!isConnected"
|
|
style="flex: 1"
|
|
@keydown.enter="sendConsoleCommand"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
icon="corner-down-left"
|
|
:disabled="!isConnected"
|
|
@click="sendConsoleCommand"
|
|
>Send</Button>
|
|
</div>
|
|
</Panel>
|
|
|
|
</div>
|
|
|
|
<!-- Right sidebar -->
|
|
<div class="dash__col dash__col--side">
|
|
|
|
<!-- Resources — real stats from agent; null = '—' -->
|
|
<Panel title="Resources" subtitle="Host agent telemetry">
|
|
<div class="solo-meters">
|
|
<ResourceMeter
|
|
label="CPU"
|
|
:value="soloCpu ?? 0"
|
|
:sub="soloCpu !== null ? soloCpu + '%' : 'awaiting telemetry'"
|
|
/>
|
|
<ResourceMeter
|
|
label="Memory"
|
|
:value="soloRamPct ?? 0"
|
|
:sub="soloRamSub ?? 'awaiting telemetry'"
|
|
/>
|
|
</div>
|
|
<div v-if="soloCpu === null && soloRamMb === null" class="meters-note">
|
|
Resource metrics arrive via the host agent heartbeat.
|
|
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
|
|
Agent setup
|
|
</Button>
|
|
</div>
|
|
</Panel>
|
|
|
|
<!-- Next wipe/reset — title follows game terminology -->
|
|
<Panel :title="'Next ' + profile.terminology.reset.toLowerCase()">
|
|
<div v-if="nextWipe" class="solo-wipe">
|
|
<div>
|
|
<div class="solo-wipe__type">{{ nextWipeType }}</div>
|
|
<div class="solo-wipe__when">{{ nextWipeLabel }}</div>
|
|
<div class="solo-wipe__name">{{ nextWipe.schedule_name }}</div>
|
|
</div>
|
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
|
|
</div>
|
|
<EmptyState
|
|
v-else
|
|
icon="calendar"
|
|
:title="'No ' + profile.terminology.reset.toLowerCase() + ' scheduled'"
|
|
:description="'Configure automatic ' + profile.terminology.reset.toLowerCase() + 's in the wipe manager.'"
|
|
>
|
|
<template #action>
|
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">
|
|
Open wipe manager
|
|
</Button>
|
|
</template>
|
|
</EmptyState>
|
|
</Panel>
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Loading state -->
|
|
<template v-else>
|
|
<div class="page__head">
|
|
<div>
|
|
<div class="t-eyebrow">Dashboard</div>
|
|
<h1 class="page__title">Server cockpit</h1>
|
|
</div>
|
|
</div>
|
|
<Panel>
|
|
<div class="dash-loading">Loading server data…</div>
|
|
</Panel>
|
|
</template>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ---------- Shared shell ---------- */
|
|
.dash { max-width: 1480px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
|
|
|
.page__head {
|
|
display: flex; align-items: flex-end; justify-content: space-between;
|
|
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
|
}
|
|
.page__title {
|
|
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
|
color: var(--text-primary); margin-top: 5px; white-space: nowrap;
|
|
}
|
|
.page__actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
|
|
|
.dash__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
|
|
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
|
|
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
|
|
/* ---------- Solo identity header ---------- */
|
|
.solo-id { display: flex; align-items: center; gap: 13px; }
|
|
.solo-id__chip {
|
|
width: 42px; height: 42px; flex: none; border-radius: var(--radius-md);
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--accent); background: var(--accent-soft);
|
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
|
}
|
|
.solo-id__name {
|
|
display: flex; align-items: center; gap: 10px;
|
|
font-size: var(--text-xl); font-weight: 700; letter-spacing: -0.01em;
|
|
color: var(--text-primary); white-space: nowrap;
|
|
}
|
|
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
|
|
|
|
/* ---------- Chart loading ---------- */
|
|
.chart-loading {
|
|
display: flex; align-items: center; justify-content: center;
|
|
height: 196px; font-size: var(--text-sm); color: var(--text-muted);
|
|
}
|
|
|
|
/* ---------- Console feed ---------- */
|
|
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
|
|
.feed--solo { max-height: 230px; }
|
|
.feed__empty {
|
|
display: flex; align-items: center; justify-content: center;
|
|
height: 100px; font-size: var(--text-sm); color: var(--text-muted);
|
|
font-style: italic;
|
|
}
|
|
|
|
/* ---------- Console bar ---------- */
|
|
.console-bar {
|
|
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
|
|
border-top: 1px solid var(--border-subtle); background: var(--surface-inset);
|
|
}
|
|
.console-bar__prompt { font-family: var(--font-mono); color: var(--accent-text); font-weight: 700; }
|
|
|
|
/* ---------- Resources ---------- */
|
|
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
|
|
.meters-note {
|
|
margin-top: 14px; font-size: var(--text-xs); color: var(--text-muted);
|
|
border-top: 1px solid var(--border-subtle); padding-top: 12px;
|
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
}
|
|
.meters-cta { margin-left: auto; }
|
|
|
|
/* ---------- Next wipe ---------- */
|
|
.solo-wipe { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
.solo-wipe__type { font-size: var(--text-xs); color: var(--text-tertiary); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: 3px; }
|
|
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
|
.solo-wipe__name { font-size: var(--text-xs); color: var(--text-muted); margin-top: 2px; }
|
|
|
|
/* ---------- Loading ---------- */
|
|
.dash-loading {
|
|
display: flex; align-items: center; justify-content: center;
|
|
padding: 60px; font-size: var(--text-sm); color: var(--text-muted);
|
|
}
|
|
|
|
/* ---------- Responsive ---------- */
|
|
@media (max-width: 1180px) {
|
|
.dash__grid { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 768px) {
|
|
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
|
}
|
|
</style>
|