Key:value chips in the live-feed event cell used the default .chip
style, which is white-space: nowrap + inline-flex. A long cmd: value
(attacker-controlled shell strings, URLs, base64 payloads) stretched
the chip horizontally past the column, pushing the whole table into
horizontal scroll and clipping subsequent columns off-screen.
Add a chip-kv variant that allows the value to wrap inside a
max-width: 100% chip (word-break: break-word, overflow-wrap: anywhere
for dense strings with no natural break). The key-label stays on the
first line via flex-shrink: 0. Short values (uid: 0, user: root)
stay tight; long ones wrap onto multiple lines inside the chip.
Also set minWidth: 0 on the EVENT td + nested flex containers so
flex children honour the column width instead of growing to fit
content. Added title={k: v} on each chip for full-value hover in
case the wrap is still clipped.
523 lines
20 KiB
TypeScript
523 lines
20 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import './Dashboard.css';
|
|
import { Shield, Users, Activity, Clock, Paperclip, Crosshair, Flame, Archive, ShieldOff, Server } from 'lucide-react';
|
|
import { parseEventBody } from '../utils/parseEventBody';
|
|
import ArtifactDrawer from './ArtifactDrawer';
|
|
import EmptyState from './EmptyState/EmptyState';
|
|
|
|
interface Stats {
|
|
total_logs: number;
|
|
unique_attackers: number;
|
|
active_deckies: number;
|
|
deployed_deckies: number;
|
|
}
|
|
|
|
interface LogEntry {
|
|
id: number;
|
|
timestamp: string;
|
|
decky: string;
|
|
service: string;
|
|
event_type: string | null;
|
|
attacker_ip: string;
|
|
raw_line: string;
|
|
fields: string | null;
|
|
msg: string | null;
|
|
severity?: string;
|
|
is_bounty?: boolean;
|
|
}
|
|
|
|
interface DashboardProps {
|
|
searchQuery: string;
|
|
}
|
|
|
|
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
|
|
|
|
const SPARK_LEN = 12;
|
|
|
|
function Sparkline({ data, alert }: { data: number[]; alert?: boolean }) {
|
|
const max = Math.max(...data, 1);
|
|
return (
|
|
<div className={`spark ${alert ? 'alert' : ''}`}>
|
|
{data.map((v, i) => (
|
|
<span
|
|
key={i}
|
|
style={{
|
|
height: `${(v / max) * 100}%`,
|
|
opacity: 0.4 + (v / max) * 0.6,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function rollWindow(prev: number[], next: number): number[] {
|
|
const out = prev.slice(-SPARK_LEN + 1);
|
|
out.push(next);
|
|
while (out.length < SPARK_LEN) out.unshift(0);
|
|
return out;
|
|
}
|
|
|
|
function computeThreat(hits5m: number): ThreatLevel {
|
|
if (hits5m > 100) return 'critical';
|
|
if (hits5m > 50) return 'elevated';
|
|
return 'nominal';
|
|
}
|
|
|
|
function getSector(): string {
|
|
try {
|
|
const raw = localStorage.getItem('decnet_tweaks');
|
|
if (!raw) return 'PRODUCTION';
|
|
const t = JSON.parse(raw);
|
|
return (t?.sector || 'PRODUCTION').toString().toUpperCase();
|
|
} catch {
|
|
return 'PRODUCTION';
|
|
}
|
|
}
|
|
|
|
const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
|
const navigate = useNavigate();
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, unknown> } | null>(null);
|
|
const [newestLogId, setNewestLogId] = useState<number | null>(null);
|
|
const [sparkTotal, setSparkTotal] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
|
|
const [sparkAttackers, setSparkAttackers] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
|
|
const [sparkBounties, setSparkBounties] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
|
|
const lastStatsRef = useRef<{ total: number; uniq: number; bounties: number } | null>(null);
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
const connect = () => {
|
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
|
|
|
const token = localStorage.getItem('token');
|
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
|
let url = `${baseUrl}/stream?token=${token}`;
|
|
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
|
|
|
|
const es = new EventSource(url);
|
|
eventSourceRef.current = es;
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const payload = JSON.parse(event.data);
|
|
if (payload.type === 'logs') {
|
|
const incoming: LogEntry[] = payload.data;
|
|
if (incoming.length > 0) {
|
|
setNewestLogId(incoming[0].id);
|
|
}
|
|
setLogs(prev => [...incoming, ...prev].slice(0, 100));
|
|
} else if (payload.type === 'stats') {
|
|
setStats(payload.data);
|
|
setLoading(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to parse SSE payload', err);
|
|
}
|
|
};
|
|
|
|
es.onerror = () => {
|
|
es.close();
|
|
eventSourceRef.current = null;
|
|
reconnectTimerRef.current = setTimeout(connect, 3000);
|
|
};
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
|
};
|
|
}, [searchQuery]);
|
|
|
|
// Tick once a second so the 5-min rolling window stays accurate even
|
|
// when logs haven't arrived.
|
|
const [nowTick, setNowTick] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
const iv = setInterval(() => setNowTick(Date.now()), 1000);
|
|
return () => clearInterval(iv);
|
|
}, []);
|
|
|
|
// Derived metrics from live log buffer
|
|
const { hits5m, alertCount, uniqueAttackers5m, bountiesCount, deckiesUnderSiege, topAttackers } = useMemo(() => {
|
|
const cutoff = nowTick - 5 * 60_000;
|
|
const recent = logs.filter(l => {
|
|
const t = Date.parse(l.timestamp);
|
|
return !isNaN(t) && t >= cutoff;
|
|
});
|
|
const alertN = recent.filter(l => l.severity === 'warn' || l.is_bounty).length;
|
|
const uniq = new Set(recent.map(l => l.attacker_ip)).size;
|
|
const bounties = logs.filter(l => l.is_bounty).length;
|
|
|
|
const deckyHits = new Map<string, number>();
|
|
for (const l of recent) deckyHits.set(l.decky, (deckyHits.get(l.decky) || 0) + 1);
|
|
const siege = Array.from(deckyHits.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.map(([name, hits]) => ({
|
|
name,
|
|
hits,
|
|
status: hits > 30 ? 'hot' : hits > 10 ? 'warn' : 'active',
|
|
}));
|
|
|
|
const attackerHits = new Map<string, number>();
|
|
for (const l of logs) attackerHits.set(l.attacker_ip, (attackerHits.get(l.attacker_ip) || 0) + 1);
|
|
const maxAttackerHits = Math.max(1, ...attackerHits.values());
|
|
const top = Array.from(attackerHits.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 4)
|
|
.map(([ip, hits]) => ({
|
|
ip,
|
|
hits,
|
|
pct: Math.min(100, (hits / maxAttackerHits) * 100),
|
|
hot: hits > maxAttackerHits * 0.6,
|
|
}));
|
|
|
|
return {
|
|
hits5m: recent.length,
|
|
alertCount: alertN,
|
|
uniqueAttackers5m: uniq,
|
|
bountiesCount: bounties,
|
|
deckiesUnderSiege: siege,
|
|
topAttackers: top,
|
|
};
|
|
}, [logs, nowTick]);
|
|
|
|
const threat = computeThreat(hits5m);
|
|
|
|
// Broadcast stats + threat for Layout's listener
|
|
useEffect(() => {
|
|
if (!stats) return;
|
|
window.dispatchEvent(new CustomEvent('decnet:stats', {
|
|
detail: { ...stats, threat, hits_5m: hits5m, alert_count: alertCount },
|
|
}));
|
|
}, [stats, threat, hits5m, alertCount]);
|
|
|
|
// Roll sparklines on each stats frame
|
|
useEffect(() => {
|
|
if (!stats) return;
|
|
const total = stats.total_logs;
|
|
const uniq = stats.unique_attackers;
|
|
const last = lastStatsRef.current;
|
|
if (last) {
|
|
const dTotal = Math.max(0, total - last.total);
|
|
const dUniq = Math.max(0, uniq - last.uniq);
|
|
const dBounties = Math.max(0, bountiesCount - last.bounties);
|
|
setSparkTotal(prev => rollWindow(prev, dTotal));
|
|
setSparkAttackers(prev => rollWindow(prev, dUniq));
|
|
setSparkBounties(prev => rollWindow(prev, dBounties));
|
|
}
|
|
lastStatsRef.current = { total, uniq, bounties: bountiesCount };
|
|
}, [stats, bountiesCount]);
|
|
|
|
if (loading && !stats) return <div className="loader">INITIALIZING SENSORS...</div>;
|
|
|
|
const sector = getSector();
|
|
|
|
return (
|
|
<div className="dashboard">
|
|
<div className="page-header">
|
|
<div className="page-title-group">
|
|
<h1>DASHBOARD</h1>
|
|
<span className="page-sub">SECTOR · {sector} · LIVE</span>
|
|
</div>
|
|
<div className="section-actions">
|
|
<span className="chip matrix fx-blink">
|
|
<span className="status-dot active" /> LIVE
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{threat === 'critical' && (
|
|
<div className="breach-banner">
|
|
<span className="pulse" />
|
|
<span style={{ flex: 1 }}>
|
|
ACTIVE BREACH — {hits5m} hits in last 5 min · {uniqueAttackers5m} attackers
|
|
</span>
|
|
<button onClick={() => navigate('/live-logs')}>INSPECT SESSION</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="stats-grid">
|
|
<div className="stat-card">
|
|
<div className="row">
|
|
<span className="stat-label">TOTAL INTERACTIONS</span>
|
|
<div className="stat-icon"><Activity size={18} /></div>
|
|
</div>
|
|
<div className="stat-value">{(stats?.total_logs ?? 0).toLocaleString()}</div>
|
|
<div className="row">
|
|
<div className="stat-delta up">+{hits5m} in last 5m</div>
|
|
<Sparkline data={sparkTotal} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card alert">
|
|
<div className="row">
|
|
<span className="stat-label">UNIQUE ATTACKERS</span>
|
|
<div className="stat-icon"><Crosshair size={18} /></div>
|
|
</div>
|
|
<div className="stat-value">{(stats?.unique_attackers ?? 0).toLocaleString()}</div>
|
|
<div className="row">
|
|
<div className="stat-delta up">{uniqueAttackers5m} active in 5m</div>
|
|
<Sparkline data={sparkAttackers} alert />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="row">
|
|
<span className="stat-label">ACTIVE DECKIES</span>
|
|
<div className="stat-icon"><Shield size={18} /></div>
|
|
</div>
|
|
<div className="stat-value">
|
|
{stats?.active_deckies ?? 0}
|
|
<span className="dim" style={{ fontSize: '1rem' }}> / {stats?.deployed_deckies ?? 0}</span>
|
|
</div>
|
|
<div className="row">
|
|
<div className="stat-delta">OF TOTAL FLEET</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="row">
|
|
<span className="stat-label">BOUNTIES CAPTURED</span>
|
|
<div className="stat-icon"><Archive size={18} /></div>
|
|
</div>
|
|
<div className="stat-value">{bountiesCount.toLocaleString()}</div>
|
|
<div className="row">
|
|
<div className="stat-delta">THIS SESSION</div>
|
|
<Sparkline data={sparkBounties} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="dash-grid">
|
|
<div className="logs-section">
|
|
<div className="section-header">
|
|
<div className="section-title">
|
|
<Clock size={16} />
|
|
<span>LIVE INTERACTION FEED</span>
|
|
<span className="chip matrix fx-blink">
|
|
<span className="status-dot active" /> LIVE
|
|
</span>
|
|
</div>
|
|
<div className="section-actions">
|
|
<span>{logs.length} RECENT</span>
|
|
</div>
|
|
</div>
|
|
<div className="logs-table-container">
|
|
<table className="logs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>TIME</th>
|
|
<th></th>
|
|
<th>DECKY</th>
|
|
<th>SVC</th>
|
|
<th>ATTACKER</th>
|
|
<th>EVENT</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.length > 0 ? logs.slice(0, 14).map(log => {
|
|
let parsedFields: Record<string, unknown> = {};
|
|
if (log.fields) {
|
|
try {
|
|
parsedFields = JSON.parse(log.fields);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
let msgHead: string | null = null;
|
|
let msgTail: string | null = null;
|
|
if (Object.keys(parsedFields).length === 0) {
|
|
const parsed = parseEventBody(log.msg);
|
|
parsedFields = parsed.fields;
|
|
msgHead = parsed.head;
|
|
msgTail = parsed.tail;
|
|
} else if (log.msg && log.msg !== '-') {
|
|
msgTail = log.msg;
|
|
}
|
|
|
|
const isAlert = log.severity === 'warn' || log.is_bounty;
|
|
const isNew = log.id === newestLogId;
|
|
|
|
return (
|
|
<tr key={log.id} className={isNew ? 'row-enter' : ''}>
|
|
<td className="dim" style={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
|
{new Date(log.timestamp).toLocaleTimeString()}
|
|
</td>
|
|
<td>
|
|
{log.is_bounty
|
|
? <span className="chip violet"><Archive size={8} /> BOUNTY</span>
|
|
: <span className={`status-dot ${isAlert ? 'hot' : 'active'}`} />}
|
|
</td>
|
|
<td className="violet-accent">{log.decky}</td>
|
|
<td><span className="chip dim-chip">{log.service}</span></td>
|
|
<td className="matrix-text">{log.attacker_ip}</td>
|
|
<td style={{ minWidth: 0, maxWidth: 0, width: '100%' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 700, fontSize: '0.78rem', color: 'var(--text-color)' }}>
|
|
{(() => {
|
|
const et = log.event_type && log.event_type !== '-' ? log.event_type : null;
|
|
const parts = [et, msgHead].filter(Boolean) as string[];
|
|
return (
|
|
<>
|
|
{parts.join(' · ')}
|
|
{msgTail && (
|
|
<span style={{ fontWeight: 'normal', opacity: 0.8 }}>
|
|
{parts.length ? ' — ' : ''}{msgTail}
|
|
</span>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
{Object.keys(parsedFields).length > 0 && (
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
|
|
{parsedFields.stored_as != null && (
|
|
<button
|
|
onClick={() => setArtifact({
|
|
decky: log.decky,
|
|
storedAs: String(parsedFields.stored_as),
|
|
fields: parsedFields,
|
|
})}
|
|
title="Inspect captured artifact"
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
fontSize: '0.62rem',
|
|
backgroundColor: 'rgba(255, 170, 0, 0.1)',
|
|
padding: '2px 8px',
|
|
borderRadius: 4,
|
|
border: '1px solid rgba(255, 170, 0, 0.5)',
|
|
color: '#ffaa00',
|
|
cursor: 'pointer',
|
|
letterSpacing: 1,
|
|
}}
|
|
>
|
|
<Paperclip size={10} /> ARTIFACT
|
|
</button>
|
|
)}
|
|
{Object.entries(parsedFields)
|
|
.filter(([k]) => k !== 'meta_json_b64' && k !== 'stored_as')
|
|
.map(([k, v]) => {
|
|
const rendered = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
return (
|
|
<span
|
|
key={k}
|
|
className="chip matrix chip-kv"
|
|
style={{ fontSize: '0.62rem' }}
|
|
title={`${k}: ${rendered}`}
|
|
>
|
|
<span className="dim" style={{ marginRight: 3 }}>{k}:</span>
|
|
{rendered}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}) : (
|
|
<tr className="empty-row">
|
|
<td colSpan={6} style={{ padding: 0 }}>
|
|
<EmptyState
|
|
icon={Activity}
|
|
title="NO INTERACTION DETECTED"
|
|
hint="waiting for the first decky hit"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="dash-side">
|
|
<div className="logs-section">
|
|
<div className="section-header">
|
|
<div className="section-title">
|
|
<Flame size={16} />
|
|
<span>DECKIES UNDER SIEGE</span>
|
|
</div>
|
|
</div>
|
|
{deckiesUnderSiege.length > 0 ? (
|
|
<div className="panel-body">
|
|
{deckiesUnderSiege.map(d => (
|
|
<div
|
|
key={d.name}
|
|
className="attacker-row"
|
|
onClick={() => window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-decky', payload: d.name } }))}
|
|
>
|
|
<span className={`status-dot ${d.status}`} />
|
|
<span className="violet-accent" style={{ width: 110, fontSize: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
|
|
<div className="attacker-bar-wrap">
|
|
<div
|
|
className={`attacker-bar ${d.status === 'hot' ? 'hot' : ''}`}
|
|
style={{ width: `${Math.min(100, (d.hits / Math.max(1, deckiesUnderSiege[0].hits)) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="dim" style={{ fontSize: '0.7rem', width: 32, textAlign: 'right' }}>{d.hits}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState icon={Server} title="NO ACTIVITY" hint="all deckies quiet" />
|
|
)}
|
|
</div>
|
|
|
|
<div className="logs-section">
|
|
<div className="section-header">
|
|
<div className="section-title">
|
|
<Users size={16} />
|
|
<span>TOP ATTACKERS</span>
|
|
</div>
|
|
</div>
|
|
{topAttackers.length > 0 ? (
|
|
<div className="panel-body">
|
|
{topAttackers.map(a => (
|
|
<div
|
|
key={a.ip}
|
|
className="attacker-row"
|
|
onClick={() => window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-attacker', payload: a.ip } }))}
|
|
>
|
|
<span className={`chip ${a.hot ? 'alert-chip' : 'dim-chip'}`} style={{ minWidth: 34, textAlign: 'center', justifyContent: 'center' }}>??</span>
|
|
<span className="matrix-text" style={{ flex: 1, fontSize: '0.7rem', fontVariantNumeric: 'tabular-nums', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.ip}</span>
|
|
<div className="attacker-bar-wrap">
|
|
<div
|
|
className={`attacker-bar ${a.hot ? 'hot' : ''}`}
|
|
style={{ width: `${a.pct}%` }}
|
|
/>
|
|
</div>
|
|
<span className="dim" style={{ fontSize: '0.7rem', width: 32, textAlign: 'right' }}>{a.hits}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<EmptyState icon={ShieldOff} title="NO ATTACKERS YET" hint="nothing on the radar" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{artifact && (
|
|
<ArtifactDrawer
|
|
decky={artifact.decky}
|
|
storedAs={artifact.storedAs}
|
|
fields={artifact.fields as Record<string, string>}
|
|
onClose={() => setArtifact(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Dashboard;
|