fix(v2): comprehensive bug fix round — all reported issues
1. Server stats: now shows player count, latency (rounded), uptime hours
2. Rares/Kills counters: fixed API response fields (all_time/total)
3. Chat send: wired socket.send with v1 envelope { player_name, command }
4. Stats button: opens Grafana iframe grid (4 panels, time range selector)
5. Char button: opens character window with attributes/skills/vitals from
/character-stats/{name} API, structured display with sections
6. Inventory button: full inventory window with equipment table (material,
set, imbue, AL, dmg, work, tink) + pack contents pill grid + filter
7. Radar button: opens radar window, sends start/stop commands via socket
8. Sidebar links: added Inventory Search, Suitbuilder, Player Debug
9. Color palette: expanded from 30 to 60 distinct colors matching v1
10. Window types properly routed: stats- prefix → Grafana, char- → character
data, inv- → inventory, radar- → radar with socket commands
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de7b547349
commit
b77450b6eb
19 changed files with 529 additions and 223 deletions
|
|
@ -9,14 +9,13 @@ interface CombatStatsResponse {
|
|||
stats: CombatStatsMessage[];
|
||||
}
|
||||
|
||||
interface CountResponse {
|
||||
total_rares?: number;
|
||||
total_kills?: number;
|
||||
count?: number;
|
||||
}
|
||||
// v1 response shapes: /total-rares → { all_time, today }, /total-kills → { total }
|
||||
interface RaresResponse { all_time: number; today: number; }
|
||||
interface KillsResponse { total: 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');
|
||||
export const getTotalRares = () => apiFetch<RaresResponse>('/total-rares');
|
||||
export const getTotalKills = () => apiFetch<KillsResponse>('/total-kills');
|
||||
export const getCharacterStats = (name: string) => apiFetch<Record<string, unknown>>(`/character-stats/${encodeURIComponent(name)}`);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
|||
showHeatmap={showHeatmap}
|
||||
showPortals={showPortals}
|
||||
/>
|
||||
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} />
|
||||
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} socket={data.socketRef.current} />
|
||||
<RareNotification recentRares={data.recentRares} />
|
||||
</div>
|
||||
</WindowManagerProvider>
|
||||
|
|
|
|||
|
|
@ -58,8 +58,12 @@ export const Sidebar: React.FC<Props> = ({
|
|||
|
||||
<div className="ml-server-status">
|
||||
<span className={`ml-status-dot ${isOnline ? 'online' : 'offline'}`} />
|
||||
<span className="ml-status-text">Coldeve</span>
|
||||
{serverHealth?.latency_ms != null && <span className="ml-status-latency">{serverHealth.latency_ms}ms</span>}
|
||||
<span className="ml-status-text">Coldeve {isOnline ? 'Online' : 'Offline'}</span>
|
||||
{serverHealth?.player_count != null && <span className="ml-status-detail">👥 {serverHealth.player_count}</span>}
|
||||
{serverHealth?.latency_ms != null && <span className="ml-status-detail">{Math.round(serverHealth.latency_ms)}ms</span>}
|
||||
{serverHealth?.uptime_seconds != null && (
|
||||
<span className="ml-status-detail">Up: {Math.floor(serverHealth.uptime_seconds / 3600)}h</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-counters">
|
||||
|
|
@ -68,6 +72,13 @@ export const Sidebar: React.FC<Props> = ({
|
|||
<div className="ml-counter kills"><span className="ml-counter-val">{totalKills.toLocaleString()}</span><span className="ml-counter-lbl">Kills</span></div>
|
||||
</div>
|
||||
|
||||
{/* Tool links */}
|
||||
<div className="ml-tool-links">
|
||||
<a href="/inventory.html" className="ml-tool-link">🔍 Inventory Search</a>
|
||||
<a href="/suitbuilder.html" className="ml-tool-link">🛡️ Suitbuilder</a>
|
||||
<a href="/debug.html" className="ml-tool-link">🐛 Player Debug</a>
|
||||
</div>
|
||||
|
||||
{/* Map toggles */}
|
||||
<div className="ml-toggles">
|
||||
<label className="ml-toggle-label">
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
|
|||
|
||||
<div className="ml-pr-buttons">
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`chat-${name}`, `Chat: ${name}`, name)}>Chat</button>
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`combat-${name}`, `Combat: ${name}`, name)}>Stats</button>
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`stats-${name}`, `Stats: ${name}`, name)}>Stats</button>
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`inv-${name}`, `Inventory: ${name}`, name)}>Inv</button>
|
||||
<button className="ml-btn" onClick={() => openWindow(`char-${name}`, `Character: ${name}`, name)}>Char</button>
|
||||
<button className="ml-btn" onClick={() => openWindow(`radar-${name}`, `Radar: ${name}`, name)}>Radar</button>
|
||||
|
|
|
|||
|
|
@ -2,58 +2,88 @@ import React, { useEffect, useState } from 'react';
|
|||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
charName: string;
|
||||
zIndex: number;
|
||||
}
|
||||
interface Props { id: string; charName: string; zIndex: number; }
|
||||
|
||||
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
|
||||
const [data, setData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Record<string, unknown>>(`/combat-stats/${encodeURIComponent(charName)}`)
|
||||
.then(setStats).catch(() => {});
|
||||
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`)
|
||||
.then(setData).catch(() => {});
|
||||
}, [charName]);
|
||||
|
||||
const session = (stats as any)?.session;
|
||||
const sd = data?.stats_data;
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={500} height={400}>
|
||||
<div style={{ padding: 8, fontSize: '0.8rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
|
||||
{session ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>Session</strong>: {session.total_kills ?? 0} kills, {(session.total_damage_given ?? 0).toLocaleString()} dmg given
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>Monsters fought</strong>: {Object.keys(session.monsters ?? {}).filter((k: string) => k !== '__cloak_surges__').length}
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888' }}>
|
||||
<th style={{ textAlign: 'left', padding: '2px 4px' }}>Monster</th>
|
||||
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Kills</th>
|
||||
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Dmg Given</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.values(session.monsters ?? {})
|
||||
.filter((m: any) => m.name !== '__cloak_surges__')
|
||||
.sort((a: any, b: any) => (b.damage_given ?? 0) - (a.damage_given ?? 0))
|
||||
.slice(0, 30)
|
||||
.map((m: any) => (
|
||||
<tr key={m.name} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={{ padding: '2px 4px' }}>{m.name}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{m.kill_count}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{(m.damage_given ?? 0).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={550} height={500}>
|
||||
<div style={{ padding: 10, fontSize: '0.78rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
|
||||
{!sd ? (
|
||||
<div style={{ color: '#666' }}>Loading character data...</div>
|
||||
) : (
|
||||
<div style={{ color: '#666' }}>Loading combat data...</div>
|
||||
<>
|
||||
{/* Level + XP header */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
{data?.level && <span><strong>Level:</strong> {data.level}</span>}
|
||||
{data?.total_xp != null && <span><strong>Total XP:</strong> {Number(data.total_xp).toLocaleString()}</span>}
|
||||
{data?.unassigned_xp != null && <span><strong>Unassigned:</strong> {Number(data.unassigned_xp).toLocaleString()}</span>}
|
||||
{data?.luminance_earned != null && <span><strong>Luminance:</strong> {Number(data.luminance_earned).toLocaleString()}</span>}
|
||||
{data?.deaths != null && <span><strong>Deaths:</strong> {data.deaths}</span>}
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
{sd.attributes && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Attributes</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
|
||||
{Object.entries(sd.attributes).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#888' }}>{k}</span>
|
||||
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{sd.skills && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Skills</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1px 12px', fontSize: '0.72rem' }}>
|
||||
{Object.entries(sd.skills)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#888' }}>{k}</span>
|
||||
<span>{typeof v === 'object' ? (v.buffed ?? v.base ?? JSON.stringify(v)) : v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vitals */}
|
||||
{sd.vitals && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, color: '#88f', marginBottom: 4 }}>Vitals</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '2px 12px' }}>
|
||||
{Object.entries(sd.vitals).map(([k, v]: [string, any]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#888' }}>{k}</span>
|
||||
<span>{typeof v === 'object' ? (v.current ?? JSON.stringify(v)) : v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw data fallback if no structured sections */}
|
||||
{!sd.attributes && !sd.skills && !sd.vitals && (
|
||||
<pre style={{ fontSize: '0.68rem', color: '#888', whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(sd, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { wsUrl } from '../../api/client';
|
||||
|
||||
interface ChatMsg {
|
||||
text: string;
|
||||
|
|
@ -18,9 +19,10 @@ interface Props {
|
|||
charName: string;
|
||||
zIndex: number;
|
||||
messages: ChatMsg[];
|
||||
socket: WebSocket | null;
|
||||
}
|
||||
|
||||
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages }) => {
|
||||
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
|
||||
const msgsRef = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
|
|
@ -28,6 +30,15 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages })
|
|||
if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
const handleSend = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const text = input.trim();
|
||||
if (!text || !socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
// v1 envelope: { player_name, command }
|
||||
socket.send(JSON.stringify({ player_name: charName, command: text }));
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
|
||||
<div className="ml-chat-messages" ref={msgsRef}>
|
||||
|
|
@ -37,7 +48,7 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages })
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form className="ml-chat-form" onSubmit={e => { e.preventDefault(); setInput(''); }}>
|
||||
<form className="ml-chat-form" onSubmit={handleSend}>
|
||||
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} placeholder="Enter chat..." />
|
||||
</form>
|
||||
</DraggableWindow>
|
||||
|
|
|
|||
|
|
@ -17,52 +17,108 @@ interface Item {
|
|||
ItemSet?: string;
|
||||
Imbue?: string;
|
||||
EquipSkill?: string;
|
||||
Mastery?: string;
|
||||
Tinks?: number;
|
||||
WieldLevel?: number;
|
||||
ContainerId?: number;
|
||||
}
|
||||
|
||||
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=500`)
|
||||
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=1000`)
|
||||
.then(d => setItems(d.items ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [charName]);
|
||||
|
||||
const filtered = filter
|
||||
? items.filter(i => i.Name?.toLowerCase().includes(filter.toLowerCase()))
|
||||
: items;
|
||||
|
||||
// Separate equipped items (those with equipment-related fields) from pack contents
|
||||
const equipped = filtered.filter(i =>
|
||||
(i.ArmorLevel && i.ArmorLevel > 0) || (i.MaxDamage && i.MaxDamage > 0) ||
|
||||
i.Imbue || i.ItemSet || (i.Tinks && i.Tinks > 0)
|
||||
);
|
||||
const other = filtered.filter(i => !equipped.includes(i));
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={650} height={450}>
|
||||
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.75rem' }}>
|
||||
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={620} height={550}>
|
||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #333' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
placeholder="Filter items..."
|
||||
style={{ width: '100%', padding: '4px 8px', fontSize: '0.75rem', background: '#222', color: '#eee', border: '1px solid #444', borderRadius: 3, outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.73rem' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.7rem' }}>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Item</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Work</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||
<td style={{ padding: '2px 4px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.Name}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.Material || ''}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.ItemSet || ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<>
|
||||
{equipped.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '6px 8px', fontWeight: 600, color: '#88f', fontSize: '0.7rem', borderBottom: '1px solid #333' }}>
|
||||
Equipment & Notable Items ({equipped.length})
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem' }}>
|
||||
<th style={{ textAlign: 'left', padding: '3px 6px' }}>Item</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Imbue</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Wk</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Tink</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{equipped.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||
<td style={{ padding: '2px 6px', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500 }}>{item.Name}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888', fontSize: '0.68rem' }}>{item.Material || ''}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#9d9', fontSize: '0.68rem' }}>{item.ItemSet || ''}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#da8', fontSize: '0.68rem' }}>{item.Imbue || ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px', color: '#f88' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px', color: '#8af' }}>{item.Tinks && item.Tinks > 0 ? item.Tinks : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{other.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '6px 8px', fontWeight: 600, color: '#888', fontSize: '0.7rem', borderBottom: '1px solid #333' }}>
|
||||
Pack Contents ({other.length})
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2, padding: 4 }}>
|
||||
{other.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ fontSize: '0.65rem', padding: '2px 6px', background: '#252525', borderRadius: 3, color: '#aaa', cursor: 'default' }}
|
||||
title={`${item.Name}${item.Value ? ` (${item.Value} pyreal)` : ''}`}
|
||||
>
|
||||
{item.Name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
|
|
|
|||
58
frontend/src/components/windows/RadarWindow.tsx
Normal file
58
frontend/src/components/windows/RadarWindow.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
charName: string;
|
||||
zIndex: number;
|
||||
socket: WebSocket | null;
|
||||
nearbyObjects: any[];
|
||||
}
|
||||
|
||||
export const RadarWindow: React.FC<Props> = ({ id, charName, zIndex, socket, nearbyObjects }) => {
|
||||
// Send start_radar when window opens, stop_radar when it closes
|
||||
useEffect(() => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ player_name: charName, command: '/mm radar start' }));
|
||||
}
|
||||
return () => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ player_name: charName, command: '/mm radar stop' }));
|
||||
}
|
||||
};
|
||||
}, [charName, socket]);
|
||||
|
||||
const objects = nearbyObjects || [];
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Radar: ${charName}`} zIndex={zIndex} width={450} height={400}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', fontSize: '0.73rem' }}>
|
||||
{objects.length === 0 ? (
|
||||
<div style={{ padding: 16, color: '#666', textAlign: 'center' }}>
|
||||
Waiting for nearby objects data...<br/>
|
||||
<span style={{ fontSize: '0.65rem' }}>The plugin will start sending radar data shortly.</span>
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.65rem' }}>
|
||||
<th style={{ textAlign: 'left', padding: '3px 6px' }}>Name</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Type</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{objects.map((obj: any, i: number) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||
<td style={{ padding: '2px 6px' }}>{obj.name}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{obj.type || obj.object_class || ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{obj.distance ? `${Math.round(obj.distance)}m` : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
54
frontend/src/components/windows/StatsWindow.tsx
Normal file
54
frontend/src/components/windows/StatsWindow.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
|
||||
interface Props { id: string; charName: string; zIndex: number; }
|
||||
|
||||
const PANELS = [
|
||||
{ title: 'Kills per Hour', id: 1 },
|
||||
{ title: 'Memory (MB)', id: 2 },
|
||||
{ title: 'CPU (%)', id: 3 },
|
||||
{ title: 'Mem Handles', id: 4 },
|
||||
];
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: '1H', value: 'now-1h' },
|
||||
{ label: '6H', value: 'now-6h' },
|
||||
{ label: '24H', value: 'now-24h' },
|
||||
{ label: '7D', value: 'now-7d' },
|
||||
];
|
||||
|
||||
export const StatsWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [timeRange, setTimeRange] = useState('now-24h');
|
||||
|
||||
const iframeUrl = (panelId: number) =>
|
||||
`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${panelId}&var-character=${encodeURIComponent(charName)}&from=${timeRange}&to=now&theme=light`;
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Stats: ${charName}`} zIndex={zIndex} width={750} height={480}>
|
||||
<div className="ml-stats-controls">
|
||||
{TIME_RANGES.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
className={`ml-stats-range-btn ${timeRange === r.value ? 'active' : ''}`}
|
||||
onClick={() => setTimeRange(r.value)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-stats-grid">
|
||||
{PANELS.map(p => (
|
||||
<div key={p.id} className="ml-stats-panel">
|
||||
<iframe
|
||||
src={iframeUrl(p.id)}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="0"
|
||||
title={p.title}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,16 +1,19 @@
|
|||
import React from 'react';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
import { ChatWindow } from './ChatWindow';
|
||||
import { StatsWindow } from './StatsWindow';
|
||||
import { CharacterWindow } from './CharacterWindow';
|
||||
import { InventoryWindow } from './InventoryWindow';
|
||||
import { RadarWindow } from './RadarWindow';
|
||||
import type { CharacterState } from '../../types';
|
||||
|
||||
interface Props {
|
||||
characters: Map<string, CharacterState>;
|
||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||
socket: WebSocket | null;
|
||||
}
|
||||
|
||||
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) => {
|
||||
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages, socket }) => {
|
||||
const { windows } = useWindowManager();
|
||||
|
||||
return (
|
||||
|
|
@ -19,18 +22,26 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) =>
|
|||
const charName = w.charName ?? '';
|
||||
|
||||
if (w.id.startsWith('chat-')) {
|
||||
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={chatMessages.get(charName) ?? []} />;
|
||||
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
messages={chatMessages.get(charName) ?? []} socket={socket} />;
|
||||
}
|
||||
if (w.id.startsWith('char-') || w.id.startsWith('combat-')) {
|
||||
if (w.id.startsWith('stats-')) {
|
||||
return <StatsWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
if (w.id.startsWith('char-')) {
|
||||
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
if (w.id.startsWith('inv-')) {
|
||||
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
// Fallback: generic window with placeholder
|
||||
return (
|
||||
<ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={[]} />
|
||||
);
|
||||
if (w.id.startsWith('radar-')) {
|
||||
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||
socket={socket} nearbyObjects={[]} />;
|
||||
}
|
||||
if (w.id.startsWith('combat-')) {
|
||||
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface DashboardState {
|
|||
totalKills: number;
|
||||
recentRares: RareMessage[];
|
||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||
socketRef: React.RefObject<WebSocket | null>;
|
||||
}
|
||||
|
||||
export function useLiveData(): DashboardState {
|
||||
|
|
@ -63,7 +64,7 @@ export function useLiveData(): DashboardState {
|
|||
}
|
||||
}, [updateChar]);
|
||||
|
||||
useWebSocket(handleWS);
|
||||
const socketRef = useWebSocket(handleWS);
|
||||
|
||||
// HTTP polls as fallback/initial load
|
||||
useEffect(() => {
|
||||
|
|
@ -131,8 +132,8 @@ export function useLiveData(): DashboardState {
|
|||
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);
|
||||
setTotalRares((rares as any).all_time ?? 0);
|
||||
setTotalKills((kills as any).total ?? 0);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
fetch();
|
||||
|
|
@ -140,5 +141,5 @@ export function useLiveData(): DashboardState {
|
|||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages };
|
||||
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages, socketRef };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
|
||||
// Matches v1 script.js PALETTE — 30 accessible colors
|
||||
// Matches v1 script.js PALETTE — 60 distinct high-contrast colors
|
||||
const PALETTE = [
|
||||
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f',
|
||||
'#bcbd22','#17becf','#aec7e8','#ffbb78','#98df8a','#ff9896','#c5b0d5','#c49c94',
|
||||
'#f7b6d2','#c7c7c7','#dbdb8d','#9edae5','#393b79','#637939','#8c6d31','#843c39',
|
||||
'#7b4173','#5254a3','#6b6ecf','#9c9ede','#d6616b','#ce6dbd',
|
||||
// Original colorblind-friendly (10)
|
||||
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd',
|
||||
'#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf',
|
||||
// Extended high-contrast (10)
|
||||
'#ff4444','#44ff44','#4444ff','#ffff44','#ff44ff',
|
||||
'#44ffff','#ff8844','#88ff44','#4488ff','#ff4488',
|
||||
// Darker variants (10)
|
||||
'#cc3333','#33cc33','#3333cc','#cccc33','#cc33cc',
|
||||
'#33cccc','#cc6633','#66cc33','#3366cc','#cc3366',
|
||||
// Brighter variants (10)
|
||||
'#ff6666','#66ff66','#6666ff','#ffff66','#ff66ff',
|
||||
'#66ffff','#ffaa66','#aaff66','#66aaff','#ff66aa',
|
||||
// Additional distinct (10)
|
||||
'#990099','#009900','#000099','#990000','#009999',
|
||||
'#999900','#aa5500','#55aa00','#0055aa','#aa0055',
|
||||
// Light pastels (10)
|
||||
'#ffaaaa','#aaffaa','#aaaaff','#ffffaa','#ffaaff',
|
||||
'#aaffff','#ffccaa','#ccffaa','#aaccff','#ffaacc',
|
||||
];
|
||||
|
||||
function hashColor(name: string): string {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { WSMessage } from '../types';
|
|||
|
||||
type MessageHandler = (msg: WSMessage) => void;
|
||||
|
||||
export function useWebSocket(onMessage: MessageHandler) {
|
||||
export function useWebSocket(onMessage: MessageHandler): React.RefObject<WebSocket | null> {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<number>(0);
|
||||
const onMessageRef = useRef(onMessage);
|
||||
|
|
@ -41,4 +41,6 @@ export function useWebSocket(onMessage: MessageHandler) {
|
|||
wsRef.current = null;
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return wsRef;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,8 +66,29 @@
|
|||
}
|
||||
.ml-status-dot.online { background: #4c4; animation: ml-pulse 2s ease-in-out infinite; }
|
||||
.ml-status-dot.offline { background: #c44; }
|
||||
.ml-status-detail { color: #888; font-size: 0.7rem; }
|
||||
.ml-status-latency { margin-left: auto; color: #888; }
|
||||
|
||||
/* ── Tool links ───────────────────────────────────────── */
|
||||
.ml-tool-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ml-tool-link {
|
||||
font-size: 0.68rem;
|
||||
color: #8ac;
|
||||
text-decoration: none;
|
||||
padding: 2px 6px;
|
||||
background: rgba(68, 136, 255, 0.08);
|
||||
border: 1px solid rgba(68, 136, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ml-tool-link:hover { background: rgba(68, 136, 255, 0.18); color: #adf; }
|
||||
|
||||
@keyframes ml-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* ── Aggregate counters ───────────────────────────────── */
|
||||
|
|
@ -483,6 +504,44 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Stats window (Grafana iframes) ───────────────────── */
|
||||
.ml-stats-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.ml-stats-range-btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 0.7rem;
|
||||
background: #2a2a2a;
|
||||
color: #888;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ml-stats-range-btn.active { background: rgba(68,136,255,0.15); color: #6aadff; border-color: rgba(68,136,255,0.3); }
|
||||
|
||||
.ml-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ml-stats-panel {
|
||||
min-height: 200px;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ml-stats-panel iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ── Chat window ──────────────────────────────────────── */
|
||||
.ml-chat-messages {
|
||||
flex: 1;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
120
static/v2/assets/index-Cr_LEFjh.js
Normal file
120
static/v2/assets/index-Cr_LEFjh.js
Normal file
File diff suppressed because one or more lines are too long
1
static/v2/assets/index-DrsM2PEe.css
Normal file
1
static/v2/assets/index-DrsM2PEe.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mosswart Overlord v2</title>
|
||||
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
||||
<script type="module" crossorigin src="/v2/assets/index-BkJV_2F3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-B55o-nLL.css">
|
||||
<script type="module" crossorigin src="/v2/assets/index-Cr_LEFjh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-DrsM2PEe.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue