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
|
|
@ -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;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue