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

21
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,21 @@
import { Layout } from './components/Layout';
import { GlobalStats } from './components/GlobalStats';
import { CharacterGrid } from './components/CharacterGrid';
import { useLiveData } from './hooks/useLiveData';
import './styles/global.css';
export default function App() {
const { characters, serverHealth, totalRares, totalKills } = useLiveData();
return (
<Layout>
<GlobalStats
activeChars={characters.size}
totalKills={totalKills}
totalRares={totalRares}
serverHealth={serverHealth}
/>
<CharacterGrid characters={characters} />
</Layout>
);
}

View file

@ -0,0 +1,15 @@
// In production the browser hits /api/* and Nginx strips the prefix.
// In dev, Vite's proxy does the same stripping.
// So we always use /api/ as prefix — works both environments.
const API_BASE = '/api';
export async function apiFetch<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
return res.json();
}
export function wsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/api/ws/live`;
}

View file

@ -0,0 +1,22 @@
import { apiFetch } from './client';
import type { TelemetrySnapshot, CombatStatsMessage, ServerHealth } from '../types';
interface LiveResponse {
players: TelemetrySnapshot[];
}
interface CombatStatsResponse {
stats: CombatStatsMessage[];
}
interface CountResponse {
total_rares?: number;
total_kills?: number;
count?: number;
}
export const getLive = () => apiFetch<LiveResponse>('/live');
export const getCombatStats = () => apiFetch<CombatStatsResponse>('/combat-stats');
export const getServerHealth = () => apiFetch<ServerHealth>('/server-health');
export const getTotalRares = () => apiFetch<CountResponse>('/total-rares');
export const getTotalKills = () => apiFetch<CountResponse>('/total-kills');

View file

@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { VitalBar } from './VitalBar';
import type { CharacterState } from '../types';
interface Props {
character: CharacterState;
}
const vtankBadge = (state: string) => {
const s = (state || 'idle').toLowerCase();
if (s === 'combat') return { label: 'Combat', cls: 'badge-combat' };
if (s === 'nav' || s === 'navigation') return { label: 'Nav', cls: 'badge-nav' };
return { label: 'Idle', cls: 'badge-idle' };
};
export const CharacterCard: React.FC<Props> = React.memo(({ character }) => {
const [expanded, setExpanded] = useState(false);
const { telemetry: t, vitals: v, combat: c } = character;
const badge = vtankBadge(t?.vt_state ?? '');
return (
<div className="char-card" onClick={() => setExpanded(!expanded)}>
<div className="char-header">
<span className="char-name">{character.name}</span>
<span className={`char-badge ${badge.cls}`}>{badge.label}</span>
</div>
{v ? (
<div className="char-vitals">
<VitalBar label="HP" current={v.health_current} max={v.health_max}
color="linear-gradient(90deg, #ff4444, #ff6666)" bgColor="#330000" />
<VitalBar label="ST" current={v.stamina_current} max={v.stamina_max}
color="linear-gradient(90deg, #ffaa00, #ffcc44)" bgColor="#331a00" />
<VitalBar label="MN" current={v.mana_current} max={v.mana_max}
color="linear-gradient(90deg, #4488ff, #66aaff)" bgColor="#001433" />
</div>
) : (
<div className="char-vitals-placeholder">Awaiting vitals...</div>
)}
<div className="char-stats-row">
<div className="stat">
<span className="stat-value">{t?.kills_per_hour ?? '--'}</span>
<span className="stat-label">kills/hr</span>
</div>
<div className="stat">
<span className="stat-value">{t?.kills?.toLocaleString() ?? '--'}</span>
<span className="stat-label">kills</span>
</div>
<div className="stat">
<span className="stat-value">{t?.deaths ?? '0'}</span>
<span className="stat-label">deaths</span>
</div>
<div className="stat">
<span className="stat-value">{t?.onlinetime?.replace(/^00\./, '') ?? '--'}</span>
<span className="stat-label">uptime</span>
</div>
</div>
{t && (
<div className="char-location">
{t.ns?.toFixed(1)}N, {t.ew?.toFixed(1)}E
</div>
)}
{expanded && (
<div className="char-expanded">
{v?.vitae ? <div className="vitae-warn">Vitae: {v.vitae}%</div> : null}
<div className="expanded-row">
<span>Prismatics: {t?.prismatic_taper_count ?? '--'}</span>
<span>Total Deaths: {t?.total_deaths ?? '--'}</span>
</div>
{c?.session && (
<div className="expanded-row">
<span>Session Dmg: {c.session.total_damage_given?.toLocaleString()}</span>
<span>Session Kills: {c.session.total_kills}</span>
</div>
)}
<div className="expanded-row">
<span>RAM: {t?.mem_mb ? (t.mem_mb / 1048576).toFixed(0) + ' MB' : '--'}</span>
<span>CPU: {t?.cpu_pct?.toFixed(1) ?? '--'}%</span>
</div>
</div>
)}
</div>
);
});
CharacterCard.displayName = 'CharacterCard';

View file

@ -0,0 +1,27 @@
import React, { useMemo } from 'react';
import { CharacterCard } from './CharacterCard';
import type { CharacterState } from '../types';
interface Props {
characters: Map<string, CharacterState>;
}
export const CharacterGrid: React.FC<Props> = ({ characters }) => {
const sorted = useMemo(() => {
return Array.from(characters.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [characters]);
if (sorted.length === 0) {
return <div className="grid-empty">No active characters</div>;
}
return (
<div className="char-grid">
{sorted.map(ch => (
<CharacterCard key={ch.name} character={ch} />
))}
</div>
);
};

View file

@ -0,0 +1,36 @@
import React from 'react';
import type { ServerHealth } from '../types';
interface Props {
activeChars: number;
totalKills: number;
totalRares: number;
serverHealth: ServerHealth | null;
}
export const GlobalStats: React.FC<Props> = ({ activeChars, totalKills, totalRares, serverHealth }) => {
const serverStatus = serverHealth?.status?.toLowerCase() ?? 'unknown';
const isOnline = serverStatus === 'online' || serverStatus === 'up';
return (
<div className="global-stats">
<div className="global-stat">
<span className="global-value">{activeChars}</span>
<span className="global-label">Active Characters</span>
</div>
<div className="global-stat">
<span className="global-value">{totalKills.toLocaleString()}</span>
<span className="global-label">Total Kills</span>
</div>
<div className="global-stat">
<span className="global-value">{totalRares}</span>
<span className="global-label">Total Rares</span>
</div>
<div className="global-stat">
<span className={`server-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="global-value">{serverHealth?.latency_ms ?? '--'}ms</span>
<span className="global-label">Coldeve</span>
</div>
</div>
);
};

View file

@ -0,0 +1,23 @@
import React from 'react';
interface Props {
children: React.ReactNode;
}
export const Layout: React.FC<Props> = ({ children }) => {
return (
<div className="dashboard">
<header className="dashboard-header">
<h1 className="dashboard-title">Mosswart Overlord</h1>
<nav className="dashboard-nav">
<a href="/" className="nav-link">Classic View</a>
<a href="/inventory.html" className="nav-link">Inventory</a>
<a href="/suitbuilder.html" className="nav-link">Suitbuilder</a>
</nav>
</header>
<main className="dashboard-main">
{children}
</main>
</div>
);
};

View file

@ -0,0 +1,24 @@
import React from 'react';
interface Props {
label: string;
current: number;
max: number;
color: string;
bgColor: string;
}
export const VitalBar: React.FC<Props> = React.memo(({ label, current, max, color, bgColor }) => {
const pct = max > 0 ? Math.min(100, Math.max(0, (current / max) * 100)) : 0;
return (
<div className="vital-bar">
<span className="vital-label">{label}</span>
<div className="vital-track" style={{ backgroundColor: bgColor }}>
<div className="vital-fill" style={{ width: `${pct}%`, background: color }} />
</div>
<span className="vital-text">{current}/{max}</span>
</div>
);
});
VitalBar.displayName = 'VitalBar';

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]);
}

9
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -0,0 +1,304 @@
/* ── Reset & Variables ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-body: #0d0d0d;
--bg-card: #1a1a1a;
--bg-card-hover: #222;
--bg-header: #111;
--border: #333;
--text: #ddd;
--text-muted: #888;
--text-dim: #555;
--accent: #4488ff;
--hp: linear-gradient(90deg, #ff4444, #ff6666);
--hp-bg: #330000;
--sta: linear-gradient(90deg, #ffaa00, #ffcc44);
--sta-bg: #331a00;
--mana: linear-gradient(90deg, #4488ff, #66aaff);
--mana-bg: #001433;
--badge-combat: #44cc44;
--badge-nav: #ffaa00;
--badge-idle: #666;
--radius: 6px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-body);
color: var(--text);
line-height: 1.4;
min-height: 100vh;
}
/* ── Dashboard Layout ─────────────────────────────────── */
.dashboard {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.dashboard-header {
background: var(--bg-header);
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.dashboard-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--accent);
}
.dashboard-nav {
display: flex;
gap: 16px;
}
.nav-link {
color: var(--text-muted);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.nav-link:hover { color: var(--text); }
.dashboard-main {
flex: 1;
padding: 16px 24px;
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
/* ── Global Stats Bar ─────────────────────────────────── */
.global-stats {
display: flex;
gap: 24px;
padding: 12px 0;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.global-stat {
display: flex;
align-items: center;
gap: 6px;
}
.global-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--text);
}
.global-label {
font-size: 0.75rem;
color: var(--text-muted);
}
.server-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.server-dot.online { background: #44cc44; }
.server-dot.offline { background: #cc4444; }
/* ── Character Grid ───────────────────────────────────── */
.char-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.grid-empty {
text-align: center;
color: var(--text-muted);
padding: 48px;
font-size: 1rem;
}
/* ── Character Card ───────────────────────────────────── */
.char-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.char-card:hover {
background: var(--bg-card-hover);
border-color: #444;
}
.char-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.char-name {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.char-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 3px;
letter-spacing: 0.5px;
}
.badge-combat { background: rgba(68,204,68,0.2); color: var(--badge-combat); }
.badge-nav { background: rgba(255,170,0,0.2); color: var(--badge-nav); }
.badge-idle { background: rgba(100,100,100,0.2); color: var(--badge-idle); }
/* ── Vital Bars ───────────────────────────────────────── */
.char-vitals {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 8px;
}
.char-vitals-placeholder {
font-size: 0.75rem;
color: var(--text-dim);
margin-bottom: 8px;
font-style: italic;
}
.vital-bar {
display: flex;
align-items: center;
gap: 6px;
}
.vital-label {
font-size: 0.65rem;
color: var(--text-muted);
width: 20px;
text-align: right;
}
.vital-track {
flex: 1;
height: 6px;
border-radius: 3px;
overflow: hidden;
}
.vital-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease-out;
}
.vital-text {
font-size: 0.65rem;
color: var(--text-muted);
width: 65px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ── Stats Row ────────────────────────────────────────── */
.char-stats-row {
display: flex;
gap: 12px;
margin-bottom: 4px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.6rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
/* ── Location ─────────────────────────────────────────── */
.char-location {
font-size: 0.7rem;
color: var(--text-dim);
text-align: right;
}
/* ── Expanded Details ─────────────────────────────────── */
.char-expanded {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.expanded-row {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 2px;
}
.vitae-warn {
color: #ff6666;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 4px;
}
/* ── Mobile Responsive ────────────────────────────────── */
@media (max-width: 768px) {
.dashboard-header {
flex-direction: column;
gap: 8px;
padding: 10px 16px;
}
.dashboard-main {
padding: 12px 12px;
}
.global-stats {
gap: 16px;
}
.char-grid {
grid-template-columns: 1fr;
}
.char-stats-row {
gap: 8px;
}
}
@media (max-width: 480px) {
.dashboard-nav {
gap: 10px;
font-size: 0.8rem;
}
.char-card {
padding: 10px;
}
}

109
frontend/src/types/index.ts Normal file
View file

@ -0,0 +1,109 @@
// Matches the telemetry payload from the plugin + /live endpoint join
export interface TelemetrySnapshot {
character_name: string;
char_tag: string;
session_id: string;
timestamp: string;
ew: number;
ns: number;
z: number;
kills: number;
kills_per_hour: string;
onlinetime: string;
deaths: string;
total_deaths: string;
prismatic_taper_count: string;
vt_state: string;
mem_mb: number;
cpu_pct: number;
// Joined from DB in /live
total_rares?: number;
session_rares?: number;
total_kills?: number;
}
export interface VitalsMessage {
type: 'vitals';
character_name: string;
health_current: number;
health_max: number;
health_percentage: number;
stamina_current: number;
stamina_max: number;
stamina_percentage: number;
mana_current: number;
mana_max: number;
mana_percentage: number;
vitae: number;
}
export interface RareMessage {
type: 'rare';
character_name: string;
name: string;
timestamp: string;
}
export interface CombatStatsMessage {
type: 'combat_stats';
character_name: string;
session_id: string;
session: CombatSessionState | null;
lifetime: CombatSessionState | null;
}
export interface CombatSessionState {
total_damage_given: number;
total_damage_received: number;
total_kills: number;
total_aetheria_surges: number;
total_cloak_surges: number;
session_start: string;
monsters: Record<string, MonsterRecord>;
}
export interface MonsterRecord {
name: string;
kill_count: number;
damage_given: number;
damage_received: number;
aetheria_surges: number;
cloak_surges: number;
offense: Record<string, Record<string, DamageStats>>;
defense: Record<string, Record<string, DamageStats>>;
}
export interface DamageStats {
total_attacks: number;
failed_attacks: number;
crits: number;
total_normal_damage: number;
max_normal_damage: number;
total_crit_damage: number;
max_crit_damage: number;
damage: number;
}
export interface ServerHealth {
status: string;
latency_ms: number | null;
player_count: number | null;
uptime_seconds: number | null;
last_check: string | null;
}
// Merged live state for each character shown in the dashboard
export interface CharacterState {
name: string;
telemetry: TelemetrySnapshot | null;
vitals: VitalsMessage | null;
combat: CombatStatsMessage | null;
lastUpdate: number; // timestamp ms
}
export type WSMessage =
| (TelemetrySnapshot & { type: 'telemetry' })
| VitalsMessage
| CombatStatsMessage
| RareMessage
| { type: string; [key: string]: unknown };