fix(v2): all reported issues — missing windows, broken features, layouts

Missing features (now added):
1. Vital Sharing window — polls /vital-sharing/peers, shows peer vitals
   with HP/STA/MANA bars, connection status, position, tags
2. Combat Stats window — full Mag-Tools style with monster list (left),
   damage breakdown grid (right), session/lifetime toggle, element matrix
3. Issues Board window — CRUD with categories, resolve/reopen, comments
4. Quest Status — links to /quest-status.html (separate page like v1)
5. Sidebar: added Issues + Vitals buttons, Quest link, Combat button
   per player row (6 buttons now: Chat/Stats/Inv/Char/Combat/Radar)

Fixed functionality:
6. Radar — fixed command to "start_radar"/"stop_radar" (was wrong path)
7. Character window — redesigned with v1-style tabbed layout:
   Left tabs: Attributes (vitals bars + attribute grid) | Skills
   (specialized/trained grouped) | Titles
   Right tabs: Augs | Ratings | Other (allegiance)
   Header: level, race, gender, XP, luminance, deaths, skill credits
8. Stats window — proper Grafana iframe grid (4 panels 2x2) with
   time range selector (1H/6H/24H/7D)

Color palette: expanded to 60 distinct colors (was 30)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:49:16 +02:00
parent b77450b6eb
commit 52e1bcd6b8
12 changed files with 710 additions and 226 deletions

View file

@ -0,0 +1,197 @@
import React, { useEffect, useState, useMemo } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { apiFetch } from '../../api/client';
interface Props { id: string; charName: string; zIndex: number; }
const ELEMENTS = ['Typeless','Slash','Pierce','Bludgeon','Fire','Cold','Acid','Electric'];
function getDmg(side: any, atkType: string, el: string): number {
return (side?.[atkType]?.[el]?.total_normal_damage ?? 0) + (side?.[atkType]?.[el]?.total_crit_damage ?? 0);
}
function flatten(side: any) {
let r = { attacks: 0, failed: 0, crits: 0, normalDmg: 0, maxNormal: 0, critDmg: 0, maxCrit: 0 };
if (!side) return r;
for (const byEl of Object.values(side) as any[]) {
for (const s of Object.values(byEl) as any[]) {
r.attacks += s.total_attacks ?? 0;
r.failed += s.failed_attacks ?? 0;
r.crits += s.crits ?? 0;
r.normalDmg += s.total_normal_damage ?? 0;
r.maxNormal = Math.max(r.maxNormal, s.max_normal_damage ?? 0);
r.critDmg += s.total_crit_damage ?? 0;
r.maxCrit = Math.max(r.maxCrit, s.max_crit_damage ?? 0);
}
}
return r;
}
function flattenType(side: any, type: string) {
let r = { attacks: 0, failed: 0 };
const byEl = side?.[type];
if (!byEl) return r;
for (const s of Object.values(byEl) as any[]) { r.attacks += s.total_attacks ?? 0; r.failed += s.failed_attacks ?? 0; }
return r;
}
export const CombatStatsWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
const [data, setData] = useState<any>(null);
const [mode, setMode] = useState<'session' | 'lifetime'>('session');
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
const iv = setInterval(() => {
apiFetch<any>(`/combat-stats/${encodeURIComponent(charName)}`).then(setData).catch(() => {});
}, 10000);
return () => clearInterval(iv);
}, [charName]);
const state = data?.[mode];
const monsters = state?.monsters ?? {};
const names = Object.keys(monsters).filter(n => n !== '__cloak_surges__').sort();
// Aggregate for selected or all
const agg = useMemo(() => {
let offense: any = {}, defense: any = {}, aeth = 0, cloak = 0;
const list = selected ? [monsters[selected]].filter(Boolean) : names.map(n => monsters[n]);
for (const m of list) {
if (!m) continue;
for (const [at, byEl] of Object.entries(m.offense ?? {})) {
if (!offense[at]) offense[at] = {};
for (const [el, s] of Object.entries(byEl as any)) {
if (!offense[at][el]) offense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
const t = offense[at][el]; const src = s as any;
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0; t.crits += src.crits ?? 0;
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
}
}
for (const [at, byEl] of Object.entries(m.defense ?? {})) {
if (!defense[at]) defense[at] = {};
for (const [el, s] of Object.entries(byEl as any)) {
if (!defense[at][el]) defense[at][el] = { total_attacks:0, failed_attacks:0, crits:0, total_normal_damage:0, max_normal_damage:0, total_crit_damage:0, max_crit_damage:0 };
const t = defense[at][el]; const src = s as any;
t.total_attacks += src.total_attacks ?? 0; t.failed_attacks += src.failed_attacks ?? 0;
t.total_normal_damage += src.total_normal_damage ?? 0; t.max_normal_damage = Math.max(t.max_normal_damage, src.max_normal_damage ?? 0);
t.total_crit_damage += src.total_crit_damage ?? 0; t.max_crit_damage = Math.max(t.max_crit_damage, src.max_crit_damage ?? 0);
}
}
aeth += m.aetheria_surges ?? 0;
cloak += m.cloak_surges ?? 0;
}
if (monsters['__cloak_surges__'] && !selected) cloak += monsters['__cloak_surges__'].cloak_surges ?? 0;
return { offense, defense, aeth, cloak };
}, [monsters, names, selected]);
const off = flatten(agg.offense);
const defMM = flattenType(agg.defense, 'MeleeMissile');
const defMag = flattenType(agg.defense, 'Magic');
const hitRate = off.attacks > 0 ? ((off.attacks - off.failed) / off.attacks * 100).toFixed(0) : '0';
const evadeRate = defMM.attacks > 0 ? (defMM.failed / defMM.attacks * 100).toFixed(0) : '0';
const resistRate = defMag.attacks > 0 ? (defMag.failed / defMag.attacks * 100).toFixed(0) : '0';
const hits = off.attacks - off.failed;
const normalHits = hits - off.crits;
const avgN = normalHits > 0 ? Math.round(off.normalDmg / normalHits) : 0;
const avgC = off.crits > 0 ? Math.round(off.critDmg / off.crits) : 0;
const critPct = hits > 0 ? (off.crits / hits * 100).toFixed(1) : '0';
const fmtN = (n: number) => n === 0 ? '' : n.toLocaleString();
return (
<DraggableWindow id={id} title={`Combat: ${charName}`} zIndex={zIndex} width={640} height={520}>
{/* Toggle */}
<div style={{ display: 'flex', gap: 4, padding: '4px 8px', borderBottom: '1px solid #333' }}>
<button className={`ml-stats-range-btn ${mode === 'session' ? 'active' : ''}`} onClick={() => setMode('session')}>Session</button>
<button className={`ml-stats-range-btn ${mode === 'lifetime' ? 'active' : ''}`} onClick={() => setMode('lifetime')}>Lifetime</button>
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Monster list (left) */}
<div style={{ width: 240, borderRight: '1px solid #333', overflowY: 'auto', fontSize: '0.72rem' }}>
<div style={{ display: 'flex', padding: '3px 6px', borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem', fontWeight: 600 }}>
<span style={{ width: 14 }}></span><span style={{ flex: 1 }}>Monster</span>
<span style={{ width: 40, textAlign: 'right' }}>Kills</span><span style={{ width: 55, textAlign: 'right' }}>Dmg</span>
</div>
{/* All row */}
<div style={{ display: 'flex', padding: '3px 6px', cursor: 'pointer', background: selected === null ? '#2a3a4a' : '', borderBottom: '1px solid #222', color: '#ddd' }}
onClick={() => setSelected(null)}>
<span style={{ width: 14, color: '#888' }}>{selected === null ? '*' : ''}</span>
<span style={{ flex: 1 }}>All</span>
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(state?.total_kills ?? 0)}</span>
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(state?.total_damage_given ?? 0)}</span>
</div>
{names.map(n => {
const m = monsters[n];
return (
<div key={n} style={{ display: 'flex', padding: '2px 6px', cursor: 'pointer', background: selected === n ? '#2a3a4a' : '',
borderBottom: '1px solid #1a1a1a', color: '#ccc' }} onClick={() => setSelected(n)}>
<span style={{ width: 14, color: '#888' }}>{selected === n ? '*' : ''}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{n}</span>
<span style={{ width: 40, textAlign: 'right' }}>{fmtN(m.kill_count)}</span>
<span style={{ width: 55, textAlign: 'right' }}>{fmtN(m.damage_given)}</span>
</div>
);
})}
</div>
{/* Breakdown grid (right) */}
<div style={{ flex: 1, overflowY: 'auto', padding: 6, fontSize: '0.72rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ color: '#777', fontSize: '0.65rem' }}>
<th style={{ textAlign: 'left', padding: '1px 4px' }}></th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given M/M</th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Given Mag</th>
<th style={{ width: 4 }}></th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv M/M</th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}>Recv Mag</th>
<th style={{ width: 4 }}></th>
<th style={{ textAlign: 'left', padding: '1px 3px' }}>Stats</th>
<th style={{ textAlign: 'right', padding: '1px 3px' }}></th>
</tr>
</thead>
<tbody>
{ELEMENTS.map((el, i) => {
const stats = [
['Evades', defMM.attacks > 0 ? `${fmtN(defMM.attacks)} (${evadeRate}%)` : ''],
['Resists', defMag.attacks > 0 ? `${fmtN(defMag.attacks)} (${resistRate}%)` : ''],
['A.Surges', agg.aeth > 0 ? `${fmtN(agg.aeth)}` : ''],
['C.Surges', agg.cloak > 0 ? `${fmtN(agg.cloak)}` : ''],
['', ''], ['', ''],
['Av/Mx', avgN > 0 ? `${fmtN(avgN)} / ${fmtN(off.maxNormal)}` : ''],
['Crits', off.crits > 0 ? `${fmtN(off.crits)} (${critPct}%)` : ''],
][i] ?? ['', ''];
return (
<tr key={el}>
<td style={{ padding: '1px 4px', color: '#888' }}>{el}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'MeleeMissile', el))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.offense, 'Magic', el))}</td>
<td></td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'MeleeMissile', el))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(getDmg(agg.defense, 'Magic', el))}</td>
<td></td>
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>{stats[0]}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{stats[1]}</td>
</tr>
);
})}
<tr>
<td colSpan={9} style={{ height: 4 }}></td>
</tr>
<tr>
<td style={{ padding: '1px 4px', color: '#888', fontWeight: 600 }}>Total</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'MeleeMissile', e), 0))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.offense, 'Magic', e), 0))}</td>
<td></td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'MeleeMissile', e), 0))}</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(ELEMENTS.reduce((s, e) => s + getDmg(agg.defense, 'Magic', e), 0))}</td>
<td></td>
<td style={{ padding: '1px 3px', color: '#777', fontWeight: 600, fontSize: '0.65rem' }}>Total</td>
<td style={{ textAlign: 'right', padding: '1px 3px', color: '#ccc' }}>{fmtN(off.normalDmg + off.critDmg)}</td>
</tr>
</tbody>
</table>
</div>
</div>
</DraggableWindow>
);
};