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:
parent
ee30ad2636
commit
e58c05c895
24 changed files with 3213 additions and 0 deletions
133
frontend/src/hooks/useLiveData.ts
Normal file
133
frontend/src/hooks/useLiveData.ts
Normal 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 };
|
||||
}
|
||||
44
frontend/src/hooks/useWebSocket.ts
Normal file
44
frontend/src/hooks/useWebSocket.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue