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>
130 lines
4.7 KiB
Vue
130 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* PlayersChart — themed ECharts area chart of players online.
|
|
*
|
|
* 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 { computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
|
|
import * as echarts from 'echarts'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{ height?: number; data?: number[]; max?: number }>(),
|
|
{ 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
|
|
|
|
function cssVar(name: string, node?: HTMLElement): string {
|
|
return getComputedStyle(node || document.documentElement).getPropertyValue(name).trim()
|
|
}
|
|
|
|
function render(): void {
|
|
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 as number[]
|
|
|
|
chart.setOption({
|
|
animationDuration: 700,
|
|
grid: { left: 8, right: 12, top: 14, bottom: 22, containLabel: true },
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: cssVar('--surface-overlay', node) || '#1f2329',
|
|
borderColor: cssVar('--border-default', node) || 'rgba(255,255,255,0.1)',
|
|
borderWidth: 1,
|
|
textStyle: { color: cssVar('--text-primary', node) || '#fff', fontFamily: mono, fontSize: 11 },
|
|
axisPointer: { type: 'line', lineStyle: { color: accent, opacity: 0.5 } },
|
|
},
|
|
xAxis: {
|
|
type: 'category', data: hours, boundaryGap: false,
|
|
axisLine: { lineStyle: { color: grid } }, axisTick: { show: false },
|
|
axisLabel: { color: text, fontFamily: mono, fontSize: 10, interval: 3 },
|
|
},
|
|
yAxis: {
|
|
type: 'value', max: props.max,
|
|
splitLine: { lineStyle: { color: grid } },
|
|
axisLabel: { color: text, fontFamily: mono, fontSize: 10 },
|
|
},
|
|
series: [{
|
|
type: 'line', smooth: 0.4, symbol: 'none', data: series,
|
|
lineStyle: { color: accent, width: 2 },
|
|
areaStyle: {
|
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
{ offset: 0, color: accent + '55' },
|
|
{ offset: 1, color: accent + '00' },
|
|
]),
|
|
},
|
|
markLine: {
|
|
silent: true, symbol: 'none',
|
|
lineStyle: { color: text, type: 'dashed', opacity: 0.5 },
|
|
data: [{ yAxis: props.max, label: { formatter: `cap ${props.max}`, color: text, fontFamily: mono, fontSize: 9 } }],
|
|
},
|
|
}],
|
|
})
|
|
}
|
|
|
|
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())
|
|
ro.observe(el.value)
|
|
mo = new MutationObserver(render)
|
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-game', 'data-theme'] })
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
ro?.disconnect()
|
|
mo?.disconnect()
|
|
chart?.dispose()
|
|
chart = null
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<!-- 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>
|