feat(panel): GameProfile registry + real-data dashboard (remove all mock/fake data)

DashboardView now renders the REAL server from useServerStore (connection/config + live WebSocket stats) + real 24h history from /analytics/timeseries, with honest EmptyStates ('install the companion agent') when there is no data. DELETED _dashboardMock.ts (the fake 8-server fleet/feed/wipes). PlayersChart hardened: removed the DEFAULT_SERIES fallback, renders an 'awaiting telemetry' empty state instead of a fabricated curve. New gameProfiles.ts: real per-game capability/terminology/stat registry (rust/conan/soulmask/dune; dune managementModel=docker-compose), ready to wire when the backend gains a per-license game field. No fake data. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 04:52:12 -04:00
parent be57d2839a
commit f2b09b281a
4 changed files with 594 additions and 478 deletions

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
/**
* PlayersChart — themed ECharts area chart of players online.
* Reads the live design tokens (--accent etc.) from CSS so it matches the
* active theme/game, and re-renders when data-game / data-theme flip on <html>.
*
* Requires real `data` — there is NO fallback series. When `data` is absent
* or empty, an "awaiting telemetry" placeholder is shown instead of the chart.
* This is intentional: fabricated curves mislead operators.
*
* Reads live design tokens (--accent etc.) from CSS so it matches the active
* theme/game, and re-renders when data-game / data-theme flip on <html>.
*/
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import { computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import * as echarts from 'echarts'
const props = withDefaults(
@@ -12,29 +17,26 @@ const props = withDefaults(
{ height: 200, max: 200 },
)
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
const el = useTemplateRef<HTMLDivElement>('el')
let chart: echarts.ECharts | null = null
let ro: ResizeObserver | null = null
let mo: MutationObserver | null = null
const DEFAULT_SERIES = [
60, 52, 44, 38, 33, 30, 34, 46, 62, 78, 92, 104,
118, 126, 131, 138, 142, 151, 168, 182, 176, 150, 112, 84,
]
function cssVar(name: string, node?: HTMLElement): string {
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
}
function render(): void {
if (!chart || !el.value) return
if (!chart || !el.value || !hasData.value) return
const node = el.value
const accent = cssVar('--accent', node) || '#f26622'
const grid = cssVar('--border-subtle', node) || 'rgba(255,255,255,0.06)'
const text = cssVar('--text-tertiary', node) || '#767d89'
const mono = 'JetBrains Mono, monospace'
const hours = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
const series = props.data ?? DEFAULT_SERIES
const series = props.data as number[]
chart.setOption({
animationDuration: 700,
@@ -77,6 +79,7 @@ function render(): void {
onMounted(() => {
if (!el.value) return
if (!hasData.value) return // empty-state slot renders instead
chart = echarts.init(el.value, undefined, { renderer: 'canvas' })
render()
ro = new ResizeObserver(() => chart?.resize())
@@ -94,5 +97,33 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="el" :style="{ width: '100%', height: height + 'px' }" />
<!-- Real data: render the ECharts canvas -->
<div v-if="hasData" ref="el" :style="{ width: '100%', height: height + 'px' }" />
<!-- No data: honest empty state never show a fabricated curve -->
<div
v-else
class="pc-empty"
:style="{ height: height + 'px' }"
>
<svg class="pc-empty__icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span class="pc-empty__label">Awaiting telemetry</span>
<span class="pc-empty__sub">Player data will appear once the server connects and reports stats</span>
</div>
</template>
<style scoped>
.pc-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
color: var(--text-muted);
}
.pc-empty__icon { margin-bottom: 4px; opacity: 0.5; }
.pc-empty__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-tertiary); }
.pc-empty__sub { font-size: var(--text-xs); color: var(--text-muted); max-width: 280px; text-align: center; line-height: 1.5; }
</style>

View File

@@ -0,0 +1,191 @@
/**
* gameProfiles.ts — Source of truth for per-game UI adaptation.
*
* Every game-specific label, terminology, Steam app ID, management model,
* and stat field list lives here. The dashboard, server cards, wipe manager,
* and any future multi-game surface should key off this registry — never
* hard-code game-specific strings in components.
*
* Backend status: the backend has NO game field on licenses yet. Today every
* license is implicitly Rust. This registry is ready: when the backend adds a
* `game` column to `licenses` (or `server_config`), the frontend only needs to
* read that field and call `useGameProfile(id)` — no component changes required.
*
* To add a new game: add a GameId union member and a corresponding entry in
* GAME_PROFILES. Nothing else changes.
*/
// ---------------------------------------------------------------------------
// Union types — exhaustive, never widen to string
// ---------------------------------------------------------------------------
/** Every supported game identifier. */
export type GameId = 'rust' | 'conan' | 'soulmask' | 'dune'
/** How the server process is managed. */
export type ManagementModel = 'process+rcon' | 'docker-compose'
/** Mod ecosystem the game uses. */
export type ModSystem = 'umod' | 'workshop' | 'none'
/** Primary console / remote-admin interface. */
export type ConsoleType = 'rcon' | 'rcon+ingame' | 'rcon+gm' | 'rabbitmq'
/**
* How a "reset" is performed — each value maps to a distinct wipe code path.
* Pipe-delimited strings intentionally encode composite operations.
*/
export type ResetModel =
| 'map-bp-wipe'
| 'wipe-world-structures+decay'
| 'worlddb-delete+decay'
| 'deep-desert-coriolis-seed'
/** Cross-server or character-sharing mechanism. */
export type ClusteringModel = 'none' | 'character-transfer' | 'main-client' | 'battlegroup'
// ---------------------------------------------------------------------------
// GameProfile shape
// ---------------------------------------------------------------------------
export interface GameTerminology {
/** What the operator calls a reset / wipe. */
reset: string
/** What the operator calls plugins / mods (null if no mod system). */
mods: string | null
/** What the operator calls a player group / faction. */
group: string
}
export interface GamePorts {
game: number
query: number
rcon: number
cluster?: number
}
export interface GameProfile {
/** Human-readable game name. */
label: string
/** CSS design-token key — maps to data-game attr and --accent token. */
accent: string
managementModel: ManagementModel
steamAppId: number | { windows: number; linux: number }
/** Default ports (game-specific defaults; operator can override). */
ports?: GamePorts
mods: ModSystem
console: ConsoleType
resetModel: ResetModel
clustering: ClusteringModel
/** Available map names, if the game ships with named maps. */
maps?: string[]
terminology: GameTerminology
/** Notable game-specific mechanics that affect server administration. */
special?: string[]
/**
* Stat field labels shown on server cards and the dashboard.
* First entry is always Players; subsequent entries are game-specific.
*/
statFields: [string, string, string]
}
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
export const GAME_PROFILES: Record<GameId, GameProfile> = {
rust: {
label: 'Rust',
accent: 'rust',
managementModel: 'process+rcon',
steamAppId: 258550,
mods: 'umod',
console: 'rcon',
resetModel: 'map-bp-wipe',
clustering: 'none',
terminology: {
reset: 'Wipe',
mods: 'Plugins',
group: 'Team',
},
statFields: ['Players', 'uMod', 'Wipe'],
},
conan: {
label: 'Conan Exiles',
accent: 'conan',
managementModel: 'process+rcon',
steamAppId: 443030,
ports: { game: 7777, query: 27015, rcon: 25575 },
mods: 'workshop',
console: 'rcon+ingame',
// Player progress persists across world wipes — only structures are cleared.
resetModel: 'wipe-world-structures+decay',
clustering: 'character-transfer',
maps: ['Exiled Lands', 'Isle of Siptah'],
terminology: {
reset: 'Wipe World',
mods: 'Mods',
group: 'Clan',
},
special: ['Clans', 'Thralls', 'Avatars', 'Purge', 'PvP windows'],
statFields: ['Players', 'Clans', 'Purge'],
},
soulmask: {
label: 'Soulmask',
accent: 'soulmask',
managementModel: 'process+rcon',
// Different Steam app IDs per OS (uncommon — store this explicitly).
steamAppId: { windows: 3017310, linux: 3017300 },
ports: { game: 8777, query: 27015, rcon: 19000, cluster: 20000 },
mods: 'workshop',
console: 'rcon+gm',
resetModel: 'worlddb-delete+decay',
clustering: 'main-client',
maps: ['Cloud Mist Forest', 'Shifting Sands'],
terminology: {
reset: 'World Reset',
mods: 'Workshop Mods',
group: 'Tribe',
},
special: ['Cluster', 'Tribes'],
statFields: ['Players', 'Tribe', 'Mask'],
},
dune: {
label: 'Dune: Awakening',
accent: 'dune',
managementModel: 'docker-compose',
steamAppId: 4754530,
mods: 'none',
// Dune uses RabbitMQ for its admin messaging — not a standard RCON port.
console: 'rabbitmq',
resetModel: 'deep-desert-coriolis-seed',
clustering: 'battlegroup',
terminology: {
reset: 'Deep Desert reset',
mods: null,
group: 'Guild',
},
special: ['Sietches', 'Deep Desert', 'Bases', 'Landsraad'],
statFields: ['Players', 'Sietches', 'Control'],
},
} as const
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
/**
* Returns the GameProfile for the given id, falling back to Rust if the id is
* unknown (forward-compatibility: unknown games show Rust defaults until their
* profile is added).
*
* @example
* const profile = useGameProfile('rust')
* console.log(profile.terminology.reset) // 'Wipe'
*/
export function useGameProfile(id: string): GameProfile {
return (GAME_PROFILES as Record<string, GameProfile>)[id] ?? GAME_PROFILES.rust
}

View File

@@ -1,136 +1,92 @@
<script setup lang="ts">
/**
* DashboardView — Fleet / Solo dashboard.
* Fleet: multi-game server cockpit (representative mock data — pending multi-instance backend).
* Solo: single-server detail wired to the real useServerStore where data exists.
* DashboardView — Single-server cockpit wired entirely to real data.
*
* View toggle (Fleet / Solo) lives inside the page so the shell (DashboardLayout) stays clean.
* Routing stays at path '/', no new routes added.
* 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 } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useServerStore } from '@/stores/server'
import { useThemeGame } from '@/composables/useThemeGame'
import { useWipeStore } from '@/stores/wipe'
import { useApi } from '@/composables/useApi'
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
import { useGameProfile } from '@/config/gameProfiles'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import ServerCard from '@/components/ds/data/ServerCard.vue'
import ConsoleLine from '@/components/ds/data/ConsoleLine.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 Icon from '@/components/ds/core/Icon.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
import Input from '@/components/ds/forms/Input.vue'
import Switch from '@/components/ds/forms/Switch.vue'
import {
MOCK_SERVERS, MOCK_FEED, MOCK_WIPES, buildStats,
type MockServer, type GameKey,
} from './_dashboardMock'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import type { TimeseriesData, WipeSchedule } from '@/types'
import { safeDate } from '@/utils/formatters'
// ---- Stores / composables ----
// ---------------------------------------------------------------------------
// Stores / composables
// ---------------------------------------------------------------------------
const server = useServerStore()
const wipeStore = useWipeStore()
const router = useRouter()
const { activeGame } = useThemeGame()
const api = useApi()
// ---- View toggle ----
const VIEW_KEY = 'cc-dash-view'
const view = ref<'fleet' | 'solo'>((localStorage.getItem(VIEW_KEY) as 'fleet' | 'solo') ?? 'fleet')
function setView(v: string) {
view.value = v as 'fleet' | 'solo'
localStorage.setItem(VIEW_KEY, v)
}
// Today every license is Rust. When the backend adds a `game` field to the
// license or server_config, pass it here: useGameProfile(server.config?.game ?? 'rust')
const profile = computed(() => useGameProfile('rust'))
const viewItems = [
{ value: 'fleet', label: 'Fleet', icon: 'layout-grid' },
{ value: 'solo', label: 'Solo', icon: 'square-dashed' },
]
// ---------------------------------------------------------------------------
// Derived server state — all real, no fallbacks to fabricated values
// ---------------------------------------------------------------------------
// ---- Fleet: filter servers by activeGame ----
const serverStatus = ref<'all' | 'online' | 'offline'>('all')
const statusItems = computed(() => [
{ value: 'all', label: 'All', count: inGame.value.length },
{ value: 'online', label: 'Running', count: inGame.value.filter((s) => s.status !== 'offline').length },
{ value: 'offline', label: 'Stopped', count: inGame.value.filter((s) => s.status === 'offline').length },
])
const hasConnection = computed(() => server.connection !== null)
const isConnected = computed(() => server.connection?.connection_status === 'connected')
const GAME_LABEL: Record<string, string> = { rust: 'Rust', dune: 'Dune', conan: 'Conan Exiles', soulmask: 'Soulmask' }
const soloName = computed(() => server.config?.server_name ?? null)
const inGame = computed<MockServer[]>(() =>
activeGame.value === 'all'
? MOCK_SERVERS
: MOCK_SERVERS.filter((s) => s.game === (activeGame.value as GameKey)),
)
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)
const shownServers = computed<MockServer[]>(() => {
const sv = serverStatus.value
return inGame.value.filter((s) => {
if (sv === 'all') return true
if (sv === 'online') return s.status !== 'offline'
return s.status === 'offline'
})
// 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`
})
// ---- Fleet KPIs ----
const runningCount = computed(() => inGame.value.filter((s) => s.status !== 'offline').length)
const playersCur = computed(() => inGame.value.reduce((a, s) => a + (s.players?.cur ?? 0), 0))
const playersMax = computed(() => inGame.value.reduce((a, s) => a + (s.players?.max ?? 0), 0))
const cpuValues = computed(() => inGame.value.filter((s) => s.cpu != null).map((s) => s.cpu as number))
const avgCpu = computed<string>(() =>
cpuValues.value.length
? String(Math.round(cpuValues.value.reduce((a, b) => a + b, 0) / cpuValues.value.length))
: '—',
)
// CPU: not in ServerStats today. Show null — never fabricate.
const soloCpu = computed(() => null as number | null)
const scopeLabel = computed(() =>
activeGame.value === 'all'
? 'Fleet overview'
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} fleet`,
)
const fleetTitle = computed(() => {
if (activeGame.value === 'all') {
const games = new Set(MOCK_SERVERS.map((s) => s.game)).size
return `${MOCK_SERVERS.length} servers · ${games} games`
}
const n = inGame.value.length
const label = GAME_LABEL[activeGame.value as string] ?? activeGame.value
return `${n} ${label} server${n === 1 ? '' : 's'}`
})
const chartSubtitle = computed(() =>
activeGame.value === 'all'
? 'All servers · last 24 hours'
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} servers · last 24 hours`,
)
// ---- Chart period toggle ----
const chartPeriod = ref('24h')
const periodItems = [
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
]
// ---- Solo: real store data + representative fallbacks ----
const soloName = computed(() => server.config?.server_name ?? 'Main · 2x Vanilla')
const soloPlayers = computed(() => server.stats?.player_count ?? 0)
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? 200)
const soloFps = computed(() => server.stats?.fps ?? 59.8)
// Memory: store gives memory_usage_mb (no max), use 8192 MB representative max for %
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? 0)
const soloRamPct = computed(() => soloRamMb.value > 0 ? Math.round((soloRamMb.value / 8192) * 100) : 68)
const soloRamSub = computed(() => soloRamMb.value > 0 ? `${(soloRamMb.value / 1024).toFixed(1)} / 8 GB` : '5.4 / 8 GB')
// CPU: not in ServerStats; use representative value
const soloCpuPct = 41
// Status badge derived from connection_status
const soloStatus = computed<'online' | 'offline' | 'starting' | 'wiping'>(() => {
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' | 'starting' | 'warn'>(() => {
const soloStatusTone = computed<'online' | 'offline' | 'warn'>(() => {
if (soloStatus.value === 'online') return 'online'
if (soloStatus.value === 'starting') return 'warn'
return 'offline'
@@ -140,217 +96,282 @@ const soloStatusLabel = computed(() => {
if (soloStatus.value === 'starting') return 'Degraded'
return 'Offline'
})
const soloRegion = computed(() => {
const ip = server.connection?.server_ip
return ip ? 'Bare metal' : 'US-East'
})
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}`
return '89.142.0.7:28015'
if (ip) return ip
return null
})
const soloUptime = computed(() => {
const sec = server.stats?.uptime_seconds ?? 0
if (sec === 0) return '—'
if (sec === 0) return null
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
return `${d}d ${h}h`
if (d > 0) return `${d}d ${h}h`
return `${h}h`
})
// Representative plugin list (uMod plugin state not in backend store)
const pluginStates = ref([
{ name: 'RaidableBases', ver: '2.7.4', on: true },
{ name: 'Kits', ver: '4.3.1', on: true },
{ name: 'CorrosionTeleportGUI', ver: '1.2.0', on: true },
{ name: 'Economics', ver: '3.9.6', on: true },
{ name: 'ZLevels Remastered', ver: '3.2.0', on: false },
])
// ---------------------------------------------------------------------------
// 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() {
if (!consoleInput.value.trim()) return
server.sendCommand(consoleInput.value.trim()).catch(() => {})
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') }
// ---- Lifecycle ----
onMounted(() => {
server.fetchServer()
})
function navWipes() { router.push('/wipes') }
function navServer() { router.push('/server') }
</script>
<template>
<div class="dash">
<!-- ===== FLEET VIEW ===== -->
<template v-if="view === 'fleet'">
<!-- Page head -->
<!-- ===== NO CONNECTION: honest empty state ===== -->
<template v-if="!server.isLoading && !hasConnection">
<div class="page__head">
<div>
<div class="t-eyebrow">{{ scopeLabel }}</div>
<h1 class="page__title">{{ fleetTitle }}</h1>
</div>
<div class="page__actions">
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
<Button variant="secondary" size="sm" icon="download">Export</Button>
<Button size="sm" icon="rocket">Deploy server</Button>
</div>
</div>
<!-- KPIs -->
<div class="dash__kpis">
<StatCard icon="server" label="Servers running" :value="String(runningCount)" :unit="'/' + inGame.length" delta="+1" note="today" />
<StatCard icon="users" label="Players online" :value="String(playersCur)" :unit="'/' + playersMax" delta="+38" note="since wipe" />
<StatCard icon="cpu" :label="activeGame === 'all' ? 'Fleet CPU' : 'Avg CPU'" :value="avgCpu" :unit="avgCpu === '—' ? '' : '%'" note="reporting agents" />
<StatCard icon="server-cog" label="Agent nodes" value="2" unit="/2" note="all reporting" />
</div>
<!-- Main grid -->
<div class="dash__grid">
<!-- Left column -->
<div class="dash__col">
<!-- Players chart panel themed ECharts -->
<Panel title="Players online" :subtitle="chartSubtitle">
<template #actions>
<Tabs v-model="chartPeriod" :items="periodItems" />
</template>
<PlayersChart :height="200" :max="200" />
</Panel>
<!-- Servers list -->
<Panel :flush-body="true" title="Servers">
<template #actions>
<Tabs v-model="serverStatus" :items="statusItems" />
</template>
<div class="server__list">
<ServerCard
v-for="(s, i) in shownServers"
:key="i"
:game="s.game"
:game-icon="s.gameIcon"
:name="s.name"
:region="s.region"
:map="s.map"
:version="s.version"
:status="s.status"
:players="s.players"
:cpu="s.cpu"
:ram="s.ram"
:ram-sub="s.ramSub"
:ip="s.ip"
:stats="buildStats(s)"
/>
<div v-if="shownServers.length === 0" class="server__empty">
No servers match the current filter.
</div>
</div>
</Panel>
</div>
<!-- Right sidebar column -->
<div class="dash__col dash__col--side">
<!-- Live activity -->
<Panel :flush-body="true" title="Live activity">
<template #actions>
<Badge tone="online" :dot="true" :pulse="true">Live</Badge>
</template>
<div class="feed">
<ConsoleLine
v-for="(f, i) in MOCK_FEED"
:key="i"
:time="f.time"
:level="f.level"
:who="f.who"
>{{ f.msg }}</ConsoleLine>
</div>
</Panel>
<!-- Upcoming wipes -->
<Panel title="Upcoming wipes">
<div class="wipes">
<div
v-for="(w, i) in MOCK_WIPES"
:key="i"
class="wipe"
:data-game="w.game"
>
<div class="wipe__dot" />
<div class="wipe__body">
<div class="wipe__name">{{ w.name }}</div>
<div class="wipe__when">{{ w.when }}</div>
</div>
<Badge :tone="w.tone" size="md">{{ w.label }}</Badge>
</div>
</div>
</Panel>
<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 companion 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>
<!-- ===== SOLO VIEW ===== -->
<template v-else>
<!-- ===== SERVER COCKPIT ===== -->
<template v-else-if="hasConnection">
<!-- Page head -->
<div class="page__head">
<div class="solo-id">
<div class="solo-id__chip">
<Icon name="box" :size="21" :stroke-width="2" />
<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 }}
{{ soloName ?? 'Server' }}
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
</div>
<div class="solo-id__meta">
{{ soloRegion }} · {{ soloIp }}
<span v-if="soloUptime !== '—'"> · up {{ soloUptime }}</span>
<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">
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
<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 -->
<!-- KPIs game profile drives stat labels; null values show '' -->
<div class="dash__kpis">
<StatCard icon="users" label="Players online" :value="String(soloPlayers)" :unit="'/' + soloMaxPlayers" delta="+12" note="since wipe" />
<StatCard icon="cpu" label="CPU" :value="String(soloCpuPct)" unit="%" delta="3%" delta-dir="down" note="agent · representative" />
<StatCard icon="memory-stick" label="Memory" :value="String(soloRamPct)" unit="%" :note="soloRamSub" />
<StatCard icon="gauge" label="Server FPS" :value="String(soloFps)" delta-dir="flat" note="stable" />
<StatCard
icon="users"
:label="profile.statFields[0] + ' 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>
<!-- Solo grid -->
<!-- Main grid -->
<div class="dash__grid">
<!-- Left column -->
<div class="dash__col">
<!-- Players chart themed ECharts -->
<Panel title="Players online" :subtitle="soloName + ' · last 24 hours'">
<PlayersChart :height="196" :max="soloMaxPlayers" />
<!-- 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 panel -->
<!-- Console real WebSocket lines only -->
<Panel :flush-body="true" title="Console">
<template #actions>
<Badge tone="online" :dot="true" :pulse="soloStatus === 'online'">Live</Badge>
<Badge
:tone="isConnected ? 'online' : 'offline'"
:dot="true"
:pulse="isConnected"
>{{ isConnected ? 'Live' : 'Disconnected' }}</Badge>
</template>
<div class="feed feed--solo">
<ConsoleLine
v-for="(f, i) in MOCK_FEED"
:key="i"
:time="f.time"
:level="f.level"
:who="f.who"
>{{ f.msg }}</ConsoleLine>
<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">&gt;</span>
<Input
@@ -358,58 +379,88 @@ onMounted(() => {
: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" @click="sendConsoleCommand">Send</Button>
<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 -->
<!-- Resources real stats from agent; null = '—' -->
<Panel title="Resources" subtitle="Companion agent telemetry">
<div class="solo-meters">
<ResourceMeter label="CPU" :value="soloCpuPct" sub="representative" />
<ResourceMeter label="Memory" :value="soloRamPct" :sub="soloRamSub" />
<ResourceMeter label="Disk" :value="64" sub="representative" />
<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 companion agent heartbeat.
<Button size="sm" variant="ghost" icon="server" class="meters-cta" @click="navServer">
Agent setup
</Button>
</div>
</Panel>
<!-- Plugins -->
<Panel :flush-body="true" title="Plugins" subtitle="uMod / Oxide">
<template #actions>
<Button size="sm" variant="ghost" icon="plus" @click="router.push('/plugins')">Add</Button>
</template>
<div class="plugs">
<div
v-for="(p, i) in pluginStates"
:key="i"
class="plug"
>
<div class="plug__id">
<span class="plug__name">{{ p.name }}</span>
<span class="plug__ver">{{ p.ver }}</span>
</div>
<Switch v-model="p.on" size="sm" />
</div>
</div>
</Panel>
<!-- Next wipe -->
<!-- Next wipe real schedule from wipeStore -->
<Panel title="Next wipe">
<div class="solo-wipe">
<div v-if="nextWipe" class="solo-wipe">
<div>
<div class="solo-wipe__when">Thu · 18:00 UTC</div>
<div class="solo-wipe__sub">representative configure in wipe manager</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 wipe scheduled"
description="Configure automatic wipes 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>
@@ -431,23 +482,6 @@ onMounted(() => {
.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; }
/* ---------- Servers list ---------- */
.server__list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; }
.server__empty { grid-column: 1 / -1; font-size: var(--text-sm); color: var(--text-muted); text-align: center; padding: 24px; }
/* ---------- Live feed ---------- */
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
.feed--solo { max-height: 230px; }
/* ---------- Upcoming wipes ---------- */
.wipes { display: flex; flex-direction: column; gap: 4px; }
.wipe { display: flex; align-items: center; gap: 11px; padding: 9px 6px; border-radius: var(--radius-md); }
.wipe:hover { background: var(--surface-hover); }
.wipe__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; box-shadow: 0 0 10px -1px var(--accent-glow); }
.wipe__body { flex: 1; min-width: 0; }
.wipe__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wipe__when { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); margin-top: 1px; }
/* ---------- Solo identity header ---------- */
.solo-id { display: flex; align-items: center; gap: 13px; }
.solo-id__chip {
@@ -463,6 +497,21 @@ onMounted(() => {
}
.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;
@@ -472,24 +521,28 @@ onMounted(() => {
/* ---------- Resources ---------- */
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
/* ---------- Plugin list ---------- */
.plugs { display: flex; flex-direction: column; }
.plug { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); }
.plug:last-child { border-bottom: 0; }
.plug__id { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
.plug__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plug__ver { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
.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: center; justify-content: space-between; gap: 12px; }
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
.solo-wipe__sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
.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; }
.server__list { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.dash__kpis { grid-template-columns: repeat(2, 1fr); }

View File

@@ -1,159 +0,0 @@
/**
* Dashboard mock data — representative placeholder pending multi-instance backend.
* Current backend is single-server-per-license; the fleet view is a forward-looking
* surface that will bind to a multi-instance API. All data here is static and clearly
* labeled so it is never confused for real tenant data.
*
* Per-game fields are isolated by game key — a Dune row NEVER receives a Rust field
* like `umod`, and vice-versa. See GAME_FIELDS for the row-field contract.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ServerStatus = 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
export type GameKey = 'rust' | 'dune' | 'conan' | 'soulmask'
export interface MockServer {
game: GameKey
gameIcon: string
name: string
region: string
map: string
version: string
status: ServerStatus
players: { cur: number; max: number }
cpu?: number
ram?: number
ramSub?: string
ip: string
// Rust-only
umod?: string
wipe?: string
// Dune-only
sietches?: string
control?: string
// Conan-only
clans?: string
purge?: string
// Soulmask-only
tribe?: string
mask?: string
}
export interface MockFeedLine {
time: string
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
who?: string
msg: string
}
export interface MockWipe {
game: GameKey
name: string
when: string
tone: 'wiping' | 'starting' | 'warn' | 'online'
label: string
}
export interface StatItem {
label: string
value: string | number
}
// ---------------------------------------------------------------------------
// Fleet server roster
// ---------------------------------------------------------------------------
export const MOCK_SERVERS: MockServer[] = [
{
game: 'rust', gameIcon: 'box', name: 'Main · 2x Vanilla', region: 'US-East',
map: 'Procedural 4500', version: 'v2024.12', status: 'online',
players: { cur: 142, max: 200 }, cpu: 41, ram: 68, ramSub: '5.4 / 8 GB',
ip: '89.142.0.7:28015', umod: '14', wipe: '2d',
},
{
game: 'rust', gameIcon: 'box', name: '5x Modded · Build', region: 'US-East',
map: 'Barren 3000', version: 'v2024.12', status: 'online',
players: { cur: 38, max: 100 }, ip: '89.142.0.7:28017', umod: '27', wipe: '2d',
},
{
game: 'rust', gameIcon: 'box', name: 'Hardcore · Solo/Duo', region: 'US-West',
map: 'Procedural 3500', version: 'v2024.12', status: 'wiping',
players: { cur: 0, max: 80 }, cpu: 8, ram: 30, ramSub: '2.4 / 8 GB',
ip: '74.91.3.2:28015', umod: '9', wipe: 'now',
},
{
game: 'dune', gameIcon: 'sun', name: 'Arrakis · Hardcore', region: 'EU-Frankfurt',
map: 'Hagga Basin', version: 'v0.9.4', status: 'online',
players: { cur: 54, max: 60 }, cpu: 63, ram: 74, ramSub: '11.8 / 16 GB',
ip: '51.83.12.4:7777', sietches: '3', control: '62%',
},
{
game: 'dune', gameIcon: 'sun', name: 'Deep Desert · PvP', region: 'EU-Frankfurt',
map: 'Deep Desert', version: 'v0.9.4', status: 'starting',
players: { cur: 0, max: 40 }, ip: '51.83.12.4:7779', sietches: '0', control: '—',
},
{
game: 'dune', gameIcon: 'sun', name: 'Sietch · Roleplay', region: 'SG-Singapore',
map: 'Hagga Basin', version: 'v0.9.4', status: 'offline',
players: { cur: 0, max: 50 }, ip: '139.99.4.8:7777', sietches: '5', control: '—',
},
{
game: 'conan', gameIcon: 'swords', name: 'Exiled Lands · PvP-C', region: 'US-East',
map: 'Exiled Lands', version: 'v3.0.5', status: 'online',
players: { cur: 32, max: 40 }, cpu: 48, ram: 60, ramSub: '9.6 / 16 GB',
ip: '89.142.0.7:7777', clans: '7', purge: 'Tier 4',
},
{
game: 'soulmask', gameIcon: 'drama', name: 'Sienna Plateau · PvE', region: 'EU-Frankfurt',
map: 'Sienna Plateau', version: 'v1.4', status: 'online',
players: { cur: 18, max: 30 }, cpu: 35, ram: 52, ramSub: '8.3 / 16 GB',
ip: '51.83.12.4:8777', tribe: '4', mask: 'Jaguar',
},
]
// ---------------------------------------------------------------------------
// Per-game stat field sets — never share slots across games
// ---------------------------------------------------------------------------
function pl(s: MockServer): string {
return `${s.players.cur} / ${s.players.max}`
}
export const GAME_FIELDS: Record<GameKey, (s: MockServer) => StatItem[]> = {
rust: (s) => [{ label: 'Players', value: pl(s) }, { label: 'uMod', value: s.umod ?? '—' }, { label: 'Wipe', value: s.wipe ?? '—' }],
dune: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Sietches', value: s.sietches ?? '—' }, { label: 'Control', value: s.control ?? '—' }],
conan: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Clans', value: s.clans ?? '—' }, { label: 'Purge', value: s.purge ?? '—' }],
soulmask: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Tribe', value: s.tribe ?? '—' }, { label: 'Mask', value: s.mask ?? '—' }],
}
export function buildStats(s: MockServer): StatItem[] {
const fn = GAME_FIELDS[s.game] ?? GAME_FIELDS.rust
return fn(s)
}
// ---------------------------------------------------------------------------
// Live activity feed
// ---------------------------------------------------------------------------
export const MOCK_FEED: MockFeedLine[] = [
{ time: '18:42:07', level: 'connect', who: 'ShadowFox', msg: 'connected — 89.142.0.7' },
{ time: '18:41:55', level: 'cmd', who: 'admin', msg: 'oxide.grant group default kits.use' },
{ time: '18:41:30', level: 'kill', who: 'ironMaiden', msg: 'was killed by Scorpion (AK-47, 84m)' },
{ time: '18:40:12', level: 'warn', msg: '5x Modded agent reconnected — telemetry resuming' },
{ time: '18:39:48', level: 'chat', who: 'BlightWalker:', msg: 'anyone selling sulfur?' },
{ time: '18:38:02', level: 'info', msg: 'RaidableBases spawned Tier-3 at G14' },
{ time: '18:36:51', level: 'connect', who: 'Vex', msg: 'connected — 51.83.12.4' },
]
// ---------------------------------------------------------------------------
// Upcoming wipes
// ---------------------------------------------------------------------------
export const MOCK_WIPES: MockWipe[] = [
{ game: 'rust', name: 'Main · 2x Vanilla', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map + BP' },
{ game: 'rust', name: '5x Modded · Build', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map only' },
{ game: 'dune', name: 'Deep Desert · PvP', when: 'Sun · 12:00 UTC', tone: 'starting', label: 'Deep Desert' },
]