feat: v2 dashboard — React + Vite parallel implementation

New modern dashboard at /v2 running alongside the existing UI at /.
Same backend, same APIs, same WebSocket — zero backend changes.

Stack: React 19 + Vite + TypeScript + Recharts
Source: frontend/ — build output: static/v2/

Phase 1 delivers:
- Character overview cards in a responsive CSS Grid
  - Live HP/Stamina/Mana bars via WebSocket vitals
  - Kills/hr, total kills, deaths, session uptime
  - VTank state badge (Combat/Nav/Idle)
  - Location coordinates
  - Click to expand: combat stats, prismatic count, CPU/RAM
- Global stats header: active chars, total kills, total rares, server health
- WebSocket hook with auto-reconnect
- HTTP poll fallback for initial load + server health
- Mobile responsive (single column on narrow screens)
- Dark theme matching the MosswartOverlord palette

Build: cd frontend && npm run build
Access: /v2 (served by existing NoCacheStaticFiles mount)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 15:07:11 +02:00
parent ee30ad2636
commit e58c05c895
24 changed files with 3213 additions and 0 deletions

View file

@ -0,0 +1,133 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useWebSocket } from './useWebSocket';
import { getLive, getCombatStats, getServerHealth, getTotalRares, getTotalKills } from '../api/endpoints';
import type {
CharacterState, TelemetrySnapshot, VitalsMessage, CombatStatsMessage,
RareMessage, ServerHealth, WSMessage,
} from '../types';
export interface DashboardState {
characters: Map<string, CharacterState>;
serverHealth: ServerHealth | null;
totalRares: number;
totalKills: number;
recentRares: RareMessage[];
}
export function useLiveData(): DashboardState {
const [characters, setCharacters] = useState<Map<string, CharacterState>>(new Map());
const [serverHealth, setServerHealth] = useState<ServerHealth | null>(null);
const [totalRares, setTotalRares] = useState(0);
const [totalKills, setTotalKills] = useState(0);
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
const charsRef = useRef(characters);
charsRef.current = characters;
// Helper to update a single character's state
const updateChar = useCallback((name: string, updater: (prev: CharacterState) => CharacterState) => {
setCharacters(prev => {
const next = new Map(prev);
const existing = next.get(name) ?? { name, telemetry: null, vitals: null, combat: null, lastUpdate: 0 };
next.set(name, updater(existing));
return next;
});
}, []);
// WebSocket message handler
const handleWS = useCallback((msg: WSMessage) => {
if (!msg.type) return;
if (msg.type === 'telemetry') {
const t = msg as TelemetrySnapshot & { type: string };
updateChar(t.character_name, s => ({ ...s, telemetry: t, lastUpdate: Date.now() }));
} else if (msg.type === 'vitals') {
const v = msg as VitalsMessage;
updateChar(v.character_name, s => ({ ...s, vitals: v, lastUpdate: Date.now() }));
} else if (msg.type === 'combat_stats') {
const c = msg as CombatStatsMessage;
updateChar(c.character_name, s => ({ ...s, combat: c, lastUpdate: Date.now() }));
} else if (msg.type === 'rare') {
const r = msg as RareMessage;
setRecentRares(prev => [r, ...prev].slice(0, 50));
}
}, [updateChar]);
useWebSocket(handleWS);
// HTTP polls as fallback/initial load
useEffect(() => {
const fetchLive = async () => {
try {
const data = await getLive();
setCharacters(prev => {
const next = new Map(prev);
for (const p of data.players ?? []) {
const existing = next.get(p.character_name);
next.set(p.character_name, {
name: p.character_name,
telemetry: p,
vitals: existing?.vitals ?? null,
combat: existing?.combat ?? null,
lastUpdate: Date.now(),
});
}
// Remove stale characters not in /live response
for (const key of next.keys()) {
if (!data.players?.some(p => p.character_name === key)) {
next.delete(key);
}
}
return next;
});
} catch { /* ignore */ }
};
fetchLive();
const id = setInterval(fetchLive, 5000);
return () => clearInterval(id);
}, []);
// Combat stats poll
useEffect(() => {
const fetch = async () => {
try {
const data = await getCombatStats();
for (const s of data.stats ?? []) {
updateChar(s.character_name, prev => ({
...prev,
combat: { ...s, type: 'combat_stats' },
}));
}
} catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 30000);
return () => clearInterval(id);
}, [updateChar]);
// Server health poll
useEffect(() => {
const fetch = async () => {
try { setServerHealth(await getServerHealth()); } catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 30000);
return () => clearInterval(id);
}, []);
// Global counters poll
useEffect(() => {
const fetch = async () => {
try {
const [rares, kills] = await Promise.all([getTotalRares(), getTotalKills()]);
setTotalRares(rares.total_rares ?? rares.count ?? 0);
setTotalKills(kills.total_kills ?? kills.count ?? 0);
} catch { /* ignore */ }
};
fetch();
const id = setInterval(fetch, 300000);
return () => clearInterval(id);
}, []);
return { characters, serverHealth, totalRares, totalKills, recentRares };
}

View file

@ -0,0 +1,44 @@
import { useEffect, useRef, useCallback } from 'react';
import { wsUrl } from '../api/client';
import type { WSMessage } from '../types';
type MessageHandler = (msg: WSMessage) => void;
export function useWebSocket(onMessage: MessageHandler) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<number>(0);
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(wsUrl());
wsRef.current = ws;
ws.addEventListener('message', (evt) => {
try {
const msg = JSON.parse(evt.data) as WSMessage;
onMessageRef.current(msg);
} catch { /* ignore parse errors */ }
});
ws.addEventListener('close', () => {
wsRef.current = null;
reconnectTimer.current = window.setTimeout(connect, 2000);
});
ws.addEventListener('error', () => {
ws.close();
});
}, []);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimer.current);
wsRef.current?.close();
wsRef.current = null;
};
}, [connect]);
}