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
89
frontend/src/components/CharacterCard.tsx
Normal file
89
frontend/src/components/CharacterCard.tsx
Normal 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';
|
||||
27
frontend/src/components/CharacterGrid.tsx
Normal file
27
frontend/src/components/CharacterGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
frontend/src/components/GlobalStats.tsx
Normal file
36
frontend/src/components/GlobalStats.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
frontend/src/components/Layout.tsx
Normal file
23
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
frontend/src/components/VitalBar.tsx
Normal file
24
frontend/src/components/VitalBar.tsx
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue