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[];
|
stats: CombatStatsMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountResponse {
|
// v1 response shapes: /total-rares → { all_time, today }, /total-kills → { total }
|
||||||
total_rares?: number;
|
interface RaresResponse { all_time: number; today: number; }
|
||||||
total_kills?: number;
|
interface KillsResponse { total: number; }
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getLive = () => apiFetch<LiveResponse>('/live');
|
export const getLive = () => apiFetch<LiveResponse>('/live');
|
||||||
export const getCombatStats = () => apiFetch<CombatStatsResponse>('/combat-stats');
|
export const getCombatStats = () => apiFetch<CombatStatsResponse>('/combat-stats');
|
||||||
export const getServerHealth = () => apiFetch<ServerHealth>('/server-health');
|
export const getServerHealth = () => apiFetch<ServerHealth>('/server-health');
|
||||||
export const getTotalRares = () => apiFetch<CountResponse>('/total-rares');
|
export const getTotalRares = () => apiFetch<RaresResponse>('/total-rares');
|
||||||
export const getTotalKills = () => apiFetch<CountResponse>('/total-kills');
|
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}
|
showHeatmap={showHeatmap}
|
||||||
showPortals={showPortals}
|
showPortals={showPortals}
|
||||||
/>
|
/>
|
||||||
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} />
|
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} socket={data.socketRef.current} />
|
||||||
<RareNotification recentRares={data.recentRares} />
|
<RareNotification recentRares={data.recentRares} />
|
||||||
</div>
|
</div>
|
||||||
</WindowManagerProvider>
|
</WindowManagerProvider>
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,12 @@ export const Sidebar: React.FC<Props> = ({
|
||||||
|
|
||||||
<div className="ml-server-status">
|
<div className="ml-server-status">
|
||||||
<span className={`ml-status-dot ${isOnline ? 'online' : 'offline'}`} />
|
<span className={`ml-status-dot ${isOnline ? 'online' : 'offline'}`} />
|
||||||
<span className="ml-status-text">Coldeve</span>
|
<span className="ml-status-text">Coldeve {isOnline ? 'Online' : 'Offline'}</span>
|
||||||
{serverHealth?.latency_ms != null && <span className="ml-status-latency">{serverHealth.latency_ms}ms</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>
|
||||||
|
|
||||||
<div className="ml-counters">
|
<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 className="ml-counter kills"><span className="ml-counter-val">{totalKills.toLocaleString()}</span><span className="ml-counter-lbl">Kills</span></div>
|
||||||
</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 */}
|
{/* Map toggles */}
|
||||||
<div className="ml-toggles">
|
<div className="ml-toggles">
|
||||||
<label className="ml-toggle-label">
|
<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">
|
<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(`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 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(`char-${name}`, `Character: ${name}`, name)}>Char</button>
|
||||||
<button className="ml-btn" onClick={() => openWindow(`radar-${name}`, `Radar: ${name}`, name)}>Radar</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 { DraggableWindow } from './DraggableWindow';
|
||||||
import { apiFetch } from '../../api/client';
|
import { apiFetch } from '../../api/client';
|
||||||
|
|
||||||
interface Props {
|
interface Props { id: string; charName: string; zIndex: number; }
|
||||||
id: string;
|
|
||||||
charName: string;
|
|
||||||
zIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
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(() => {
|
useEffect(() => {
|
||||||
apiFetch<Record<string, unknown>>(`/combat-stats/${encodeURIComponent(charName)}`)
|
apiFetch<any>(`/character-stats/${encodeURIComponent(charName)}`)
|
||||||
.then(setStats).catch(() => {});
|
.then(setData).catch(() => {});
|
||||||
}, [charName]);
|
}, [charName]);
|
||||||
|
|
||||||
const session = (stats as any)?.session;
|
const sd = data?.stats_data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={500} height={400}>
|
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={550} height={500}>
|
||||||
<div style={{ padding: 8, fontSize: '0.8rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
|
<div style={{ padding: 10, fontSize: '0.78rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
|
||||||
{session ? (
|
{!sd ? (
|
||||||
<>
|
<div style={{ color: '#666' }}>Loading character data...</div>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<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>
|
</div>
|
||||||
</DraggableWindow>
|
</DraggableWindow>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { DraggableWindow } from './DraggableWindow';
|
import { DraggableWindow } from './DraggableWindow';
|
||||||
|
import { wsUrl } from '../../api/client';
|
||||||
|
|
||||||
interface ChatMsg {
|
interface ChatMsg {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -18,9 +19,10 @@ interface Props {
|
||||||
charName: string;
|
charName: string;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
messages: ChatMsg[];
|
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 msgsRef = useRef<HTMLDivElement>(null);
|
||||||
const [input, setInput] = useState('');
|
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;
|
if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
|
||||||
}, [messages.length]);
|
}, [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 (
|
return (
|
||||||
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
|
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
|
||||||
<div className="ml-chat-messages" ref={msgsRef}>
|
<div className="ml-chat-messages" ref={msgsRef}>
|
||||||
|
|
@ -37,7 +48,7 @@ export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages })
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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..." />
|
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} placeholder="Enter chat..." />
|
||||||
</form>
|
</form>
|
||||||
</DraggableWindow>
|
</DraggableWindow>
|
||||||
|
|
|
||||||
|
|
@ -17,52 +17,108 @@ interface Item {
|
||||||
ItemSet?: string;
|
ItemSet?: string;
|
||||||
Imbue?: string;
|
Imbue?: string;
|
||||||
EquipSkill?: string;
|
EquipSkill?: string;
|
||||||
|
Mastery?: string;
|
||||||
|
Tinks?: number;
|
||||||
|
WieldLevel?: number;
|
||||||
|
ContainerId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=500`)
|
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=1000`)
|
||||||
.then(d => setItems(d.items ?? []))
|
.then(d => setItems(d.items ?? []))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [charName]);
|
}, [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 (
|
return (
|
||||||
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={650} height={450}>
|
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={620} height={550}>
|
||||||
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.75rem' }}>
|
<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 ? (
|
{loading ? (
|
||||||
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
|
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
|
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<>
|
||||||
<thead>
|
{equipped.length > 0 && (
|
||||||
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.7rem' }}>
|
<>
|
||||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Item</th>
|
<div style={{ padding: '6px 8px', fontWeight: 600, color: '#88f', fontSize: '0.7rem', borderBottom: '1px solid #333' }}>
|
||||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
|
Equipment & Notable Items ({equipped.length})
|
||||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
|
</div>
|
||||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
|
<thead>
|
||||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Work</th>
|
<tr style={{ borderBottom: '1px solid #333', color: '#777', fontSize: '0.65rem' }}>
|
||||||
</tr>
|
<th style={{ textAlign: 'left', padding: '3px 6px' }}>Item</th>
|
||||||
</thead>
|
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
|
||||||
<tbody>
|
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
|
||||||
{items.map((item, i) => (
|
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Imbue</th>
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
|
||||||
<td style={{ padding: '2px 4px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.Name}</td>
|
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
|
||||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.Material || ''}</td>
|
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Wk</th>
|
||||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.ItemSet || ''}</td>
|
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Tink</th>
|
||||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
|
</tr>
|
||||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
|
</thead>
|
||||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
|
<tbody>
|
||||||
</tr>
|
{equipped.map((item, i) => (
|
||||||
))}
|
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||||
</tbody>
|
<td style={{ padding: '2px 6px', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: 500 }}>{item.Name}</td>
|
||||||
</table>
|
<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>
|
</div>
|
||||||
</DraggableWindow>
|
</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 React from 'react';
|
||||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||||
import { ChatWindow } from './ChatWindow';
|
import { ChatWindow } from './ChatWindow';
|
||||||
|
import { StatsWindow } from './StatsWindow';
|
||||||
import { CharacterWindow } from './CharacterWindow';
|
import { CharacterWindow } from './CharacterWindow';
|
||||||
import { InventoryWindow } from './InventoryWindow';
|
import { InventoryWindow } from './InventoryWindow';
|
||||||
|
import { RadarWindow } from './RadarWindow';
|
||||||
import type { CharacterState } from '../../types';
|
import type { CharacterState } from '../../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
characters: Map<string, CharacterState>;
|
characters: Map<string, CharacterState>;
|
||||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
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();
|
const { windows } = useWindowManager();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -19,18 +22,26 @@ export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) =>
|
||||||
const charName = w.charName ?? '';
|
const charName = w.charName ?? '';
|
||||||
|
|
||||||
if (w.id.startsWith('chat-')) {
|
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} />;
|
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||||
}
|
}
|
||||||
if (w.id.startsWith('inv-')) {
|
if (w.id.startsWith('inv-')) {
|
||||||
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||||
}
|
}
|
||||||
// Fallback: generic window with placeholder
|
if (w.id.startsWith('radar-')) {
|
||||||
return (
|
return <RadarWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex}
|
||||||
<ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={[]} />
|
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;
|
totalKills: number;
|
||||||
recentRares: RareMessage[];
|
recentRares: RareMessage[];
|
||||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||||
|
socketRef: React.RefObject<WebSocket | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLiveData(): DashboardState {
|
export function useLiveData(): DashboardState {
|
||||||
|
|
@ -63,7 +64,7 @@ export function useLiveData(): DashboardState {
|
||||||
}
|
}
|
||||||
}, [updateChar]);
|
}, [updateChar]);
|
||||||
|
|
||||||
useWebSocket(handleWS);
|
const socketRef = useWebSocket(handleWS);
|
||||||
|
|
||||||
// HTTP polls as fallback/initial load
|
// HTTP polls as fallback/initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -131,8 +132,8 @@ export function useLiveData(): DashboardState {
|
||||||
const fetch = async () => {
|
const fetch = async () => {
|
||||||
try {
|
try {
|
||||||
const [rares, kills] = await Promise.all([getTotalRares(), getTotalKills()]);
|
const [rares, kills] = await Promise.all([getTotalRares(), getTotalKills()]);
|
||||||
setTotalRares(rares.total_rares ?? rares.count ?? 0);
|
setTotalRares((rares as any).all_time ?? 0);
|
||||||
setTotalKills(kills.total_kills ?? kills.count ?? 0);
|
setTotalKills((kills as any).total ?? 0);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
fetch();
|
fetch();
|
||||||
|
|
@ -140,5 +141,5 @@ export function useLiveData(): DashboardState {
|
||||||
return () => clearInterval(id);
|
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';
|
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 = [
|
const PALETTE = [
|
||||||
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f',
|
// Original colorblind-friendly (10)
|
||||||
'#bcbd22','#17becf','#aec7e8','#ffbb78','#98df8a','#ff9896','#c5b0d5','#c49c94',
|
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd',
|
||||||
'#f7b6d2','#c7c7c7','#dbdb8d','#9edae5','#393b79','#637939','#8c6d31','#843c39',
|
'#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf',
|
||||||
'#7b4173','#5254a3','#6b6ecf','#9c9ede','#d6616b','#ce6dbd',
|
// 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 {
|
function hashColor(name: string): string {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { WSMessage } from '../types';
|
||||||
|
|
||||||
type MessageHandler = (msg: WSMessage) => void;
|
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 wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimer = useRef<number>(0);
|
const reconnectTimer = useRef<number>(0);
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
|
|
@ -41,4 +41,6 @@ export function useWebSocket(onMessage: MessageHandler) {
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
};
|
};
|
||||||
}, [connect]);
|
}, [connect]);
|
||||||
|
|
||||||
|
return wsRef;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,29 @@
|
||||||
}
|
}
|
||||||
.ml-status-dot.online { background: #4c4; animation: ml-pulse 2s ease-in-out infinite; }
|
.ml-status-dot.online { background: #4c4; animation: ml-pulse 2s ease-in-out infinite; }
|
||||||
.ml-status-dot.offline { background: #c44; }
|
.ml-status-dot.offline { background: #c44; }
|
||||||
|
.ml-status-detail { color: #888; font-size: 0.7rem; }
|
||||||
.ml-status-latency { margin-left: auto; color: #888; }
|
.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; } }
|
@keyframes ml-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
/* ── Aggregate counters ───────────────────────────────── */
|
/* ── Aggregate counters ───────────────────────────────── */
|
||||||
|
|
@ -483,6 +504,44 @@
|
||||||
flex-direction: column;
|
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 ──────────────────────────────────────── */
|
/* ── Chat window ──────────────────────────────────────── */
|
||||||
.ml-chat-messages {
|
.ml-chat-messages {
|
||||||
flex: 1;
|
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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Mosswart Overlord v2</title>
|
<title>Mosswart Overlord v2</title>
|
||||||
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
||||||
<script type="module" crossorigin src="/v2/assets/index-BkJV_2F3.js"></script>
|
<script type="module" crossorigin src="/v2/assets/index-Cr_LEFjh.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-B55o-nLL.css">
|
<link rel="stylesheet" crossorigin href="/v2/assets/index-DrsM2PEe.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue