feat(web/dashboard): reskin with richer live-activity panels

Rewrites Dashboard.tsx around three stacked panels — live interactions,
deckies-under-siege, and top-attackers — each with its own header,
empty state, and status accents. Dashboard.css fills in the supporting
grid + type system.
This commit is contained in:
2026-04-22 17:15:27 -04:00
parent ccbe949238
commit 1518475946
2 changed files with 718 additions and 174 deletions

View File

@@ -1,52 +1,203 @@
.dashboard {
display: flex;
flex-direction: column;
gap: 32px;
gap: 24px;
}
/* Page header */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
border-bottom: 1px solid var(--border-color);
padding-bottom: 16px;
gap: 24px;
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.page-title-group h1 {
font-size: 1.3rem;
letter-spacing: 4px;
font-weight: 700;
}
.page-sub {
font-size: 0.7rem;
opacity: 0.5;
letter-spacing: 1px;
}
/* Chips */
.chip {
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--accent);
color: var(--accent);
background: var(--accent-tint-10);
letter-spacing: 1px;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 4px;
}
.chip.violet {
border-color: var(--violet);
color: var(--violet);
background: var(--violet-tint-10);
}
.chip.matrix {
border-color: var(--matrix);
color: var(--matrix);
background: var(--matrix-tint-10);
}
.chip.dim-chip {
border-color: var(--border-color);
color: rgba(0, 255, 65, 0.6);
background: transparent;
}
.chip.alert-chip {
border-color: var(--alert);
color: var(--alert);
background: rgba(255, 65, 65, 0.1);
}
/* Breach banner */
.breach-banner {
background: rgba(255, 65, 65, 0.1);
border: 1px solid var(--alert);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 14px;
font-size: 0.78rem;
letter-spacing: 1.5px;
color: var(--alert);
}
.breach-banner .pulse {
width: 10px;
height: 10px;
background: var(--alert);
border-radius: 50%;
animation: decnet-pulse 0.7s infinite alternate;
flex-shrink: 0;
}
.breach-banner button {
background: transparent;
border: 1px solid var(--alert);
color: var(--alert);
padding: 6px 14px;
font-size: 0.7rem;
letter-spacing: 1.5px;
cursor: pointer;
font-family: inherit;
}
.breach-banner button:hover {
background: rgba(255, 65, 65, 0.15);
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-card {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
padding: 24px;
padding: 16px 18px;
display: flex;
align-items: center;
gap: 20px;
transition: all 0.3s ease;
flex-direction: column;
gap: 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.stat-card:hover {
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
border-color: var(--accent);
box-shadow: var(--accent-glow);
transform: translateY(-2px);
}
.stat-icon {
color: var(--accent-color);
filter: drop-shadow(var(--violet-glow));
.stat-card.alert {
border-color: rgba(255, 65, 65, 0.4);
}
.stat-content {
.stat-card .row {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.stat-icon {
color: var(--accent);
display: flex;
align-items: center;
}
.stat-label {
font-size: 0.7rem;
font-size: 0.65rem;
opacity: 0.6;
letter-spacing: 1px;
letter-spacing: 1.5px;
}
.stat-value {
font-size: 1.8rem;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.stat-value .dim {
opacity: 0.5;
}
.stat-delta {
font-size: 0.65rem;
letter-spacing: 1px;
opacity: 0.8;
display: flex;
align-items: center;
gap: 6px;
}
.stat-delta.up {
color: var(--alert);
}
/* Sparkline */
.spark {
display: flex;
align-items: flex-end;
gap: 2px;
height: 22px;
min-width: 80px;
}
.spark span {
flex: 1;
background: var(--accent);
opacity: 0.5;
min-height: 2px;
}
.spark.alert span {
background: var(--alert);
}
/* Section header (logs, panels) */
.logs-section {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
@@ -55,20 +206,147 @@
}
.section-header {
padding: 16px 24px;
padding: 14px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.8rem;
letter-spacing: 2px;
font-weight: 700;
}
.section-actions {
display: flex;
gap: 8px;
align-items: center;
font-size: 0.62rem;
opacity: 0.6;
letter-spacing: 1px;
}
.section-header h2 {
font-size: 0.9rem;
letter-spacing: 2px;
}
/* Dashboard grid */
.dash-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.dash-side {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Attacker/siege rows */
.attacker-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.75rem;
padding: 6px 0;
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
cursor: pointer;
}
.attacker-row:hover {
background: rgba(0, 255, 65, 0.03);
}
.attacker-row:last-child {
border-bottom: none;
}
.attacker-bar-wrap {
flex: 1;
height: 4px;
background: rgba(48, 54, 61, 0.5);
position: relative;
}
.attacker-bar {
height: 100%;
background: var(--matrix);
}
.attacker-bar.hot {
background: var(--alert);
}
.panel-body {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-empty {
padding: 20px 16px;
text-align: center;
opacity: 0.4;
font-size: 0.7rem;
letter-spacing: 1px;
}
/* Status dots (hot / warn / active) */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active {
background: var(--matrix);
box-shadow: 0 0 8px var(--matrix);
}
.status-dot.warn {
background: #ffaa00;
box-shadow: 0 0 6px rgba(255, 170, 0, 0.6);
}
.status-dot.hot {
background: var(--alert);
box-shadow: 0 0 8px var(--alert);
animation: decnet-pulse 1s infinite alternate;
}
/* Row-enter animation */
@keyframes row-enter {
from {
background: rgba(0, 255, 65, 0.2);
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: none;
}
}
.row-enter {
animation: row-enter 0.6s var(--ease);
}
/* Logs table (existing) */
.logs-table-container {
overflow-x: auto;
overflow-y: auto;
max-height: 420px;
}
.logs-table {
@@ -79,14 +357,18 @@
}
.logs-table th {
padding: 12px 24px;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
opacity: 0.5;
font-weight: normal;
position: sticky;
top: 0;
background: var(--secondary-color);
z-index: 1;
}
.logs-table td {
padding: 12px 24px;
padding: 10px 20px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
}
@@ -128,7 +410,7 @@
to { transform: rotate(360deg); }
}
/* Attacker Profiles */
/* Attacker Profiles (Attackers page) */
.attacker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
@@ -146,8 +428,8 @@
.attacker-card:hover {
transform: translateY(-2px);
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
.traversal-badge {
@@ -182,8 +464,8 @@
}
.back-button:hover {
border-color: var(--text-color);
box-shadow: var(--matrix-green-glow);
border-color: var(--accent);
box-shadow: var(--accent-glow);
}
/* Fingerprint cards */

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import './Dashboard.css';
import { Shield, Users, Activity, Clock, Paperclip } from 'lucide-react';
import { Shield, Users, Activity, Clock, Paperclip, Crosshair, Flame, Archive } from 'lucide-react';
import { parseEventBody } from '../utils/parseEventBody';
import ArtifactDrawer from './ArtifactDrawer';
@@ -21,32 +22,81 @@ interface LogEntry {
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, any> } | null>(null);
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();
}
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)}`;
}
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
const es = new EventSource(url);
eventSourceRef.current = es;
@@ -55,11 +105,14 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'logs') {
setLogs(prev => [...payload.data, ...prev].slice(0, 100));
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);
window.dispatchEvent(new CustomEvent('decnet:stats', { detail: payload.data }));
}
} catch (err) {
console.error('Failed to parse SSE payload', err);
@@ -81,144 +134,369 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
};
}, [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">
<StatCard
icon={<Activity size={32} />}
label="TOTAL INTERACTIONS"
value={stats?.total_logs || 0}
/>
<StatCard
icon={<Users size={32} />}
label="UNIQUE ATTACKERS"
value={stats?.unique_attackers || 0}
/>
<StatCard
icon={<Shield size={32} />}
label="ACTIVE DECKIES"
value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`}
/>
<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="logs-section">
<div className="section-header">
<Clock size={20} />
<h2>LIVE INTERACTION LOG</h2>
</div>
<div className="logs-table-container">
<table className="logs-table">
<thead>
<tr>
<th>TIMESTAMP</th>
<th>DECKY</th>
<th>SERVICE</th>
<th>ATTACKER IP</th>
<th>EVENT</th>
</tr>
</thead>
<tbody>
{logs.length > 0 ? logs.map(log => {
let parsedFields: Record<string, string> = {};
if (log.fields) {
try {
parsedFields = JSON.parse(log.fields);
} catch (e) {
// Ignore parsing errors
}
}
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;
}
return (
<tr key={log.id}>
<td className="dim">{new Date(log.timestamp).toLocaleString()}</td>
<td className="violet-accent">{log.decky}</td>
<td className="matrix-text">{log.service}</td>
<td>{log.attacker_ip}</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', 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 || parsedFields.stored_as) && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{parsedFields.stored_as && (
<button
onClick={() => setArtifact({
decky: log.decky,
storedAs: String(parsedFields.stored_as),
fields: parsedFields,
})}
title="Inspect captured artifact"
style={{
display: 'flex', alignItems: 'center', gap: '6px',
fontSize: '0.7rem',
backgroundColor: 'rgba(255, 170, 0, 0.1)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(255, 170, 0, 0.5)',
color: '#ffaa00',
cursor: 'pointer',
}}
>
<Paperclip size={11} /> ARTIFACT
</button>
)}
{Object.entries(parsedFields)
.filter(([k]) => k !== 'meta_json_b64')
.map(([k, v]) => (
<span key={k} style={{
fontSize: '0.7rem',
backgroundColor: 'rgba(0, 255, 65, 0.1)',
padding: '2px 8px',
borderRadius: '4px',
border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all'
}}>
<span style={{ opacity: 0.6 }}>{k}:</span> {typeof v === 'object' ? JSON.stringify(v) : v}
</span>
))}
</div>
)}
</div>
</td>
</tr>
);
}) : (
<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>
<td colSpan={5} style={{textAlign: 'center', padding: '40px'}}>NO INTERACTION DETECTED</td>
<th>TIME</th>
<th></th>
<th>DECKY</th>
<th>SVC</th>
<th>ATTACKER</th>
<th>EVENT</th>
</tr>
)}
</tbody>
</table>
</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>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<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' }}>
{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]) => (
<span key={k} className="chip matrix" style={{ fontSize: '0.62rem' }}>
<span className="dim" style={{ marginRight: 3 }}>{k}:</span>
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
</span>
))}
</div>
)}
</div>
</td>
</tr>
);
}) : (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px' }}>NO INTERACTION DETECTED</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>
) : (
<div className="panel-empty">NO ACTIVITY</div>
)}
</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>
) : (
<div className="panel-empty">NO ATTACKERS YET</div>
)}
</div>
</div>
</div>
{artifact && (
<ArtifactDrawer
decky={artifact.decky}
storedAs={artifact.storedAs}
fields={artifact.fields}
fields={artifact.fields as Record<string, string>}
onClose={() => setArtifact(null)}
/>
)}
@@ -226,20 +504,4 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
);
};
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
}
const StatCard: React.FC<StatCardProps> = ({ icon, label, value }) => (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-content">
<span className="stat-label">{label}</span>
<span className="stat-value">{value.toLocaleString()}</span>
</div>
</div>
);
export default Dashboard;