From 15184759467039fb4deea9e5af7fee013ca18440 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 17:15:27 -0400 Subject: [PATCH] feat(web/dashboard): reskin with richer live-activity panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- decnet_web/src/components/Dashboard.css | 330 +++++++++++++- decnet_web/src/components/Dashboard.tsx | 562 +++++++++++++++++------- 2 files changed, 718 insertions(+), 174 deletions(-) diff --git a/decnet_web/src/components/Dashboard.css b/decnet_web/src/components/Dashboard.css index 91889f2a..bac67bf3 100644 --- a/decnet_web/src/components/Dashboard.css +++ b/decnet_web/src/components/Dashboard.css @@ -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 */ diff --git a/decnet_web/src/components/Dashboard.tsx b/decnet_web/src/components/Dashboard.tsx index fd8319b3..39a08e1c 100644 --- a/decnet_web/src/components/Dashboard.tsx +++ b/decnet_web/src/components/Dashboard.tsx @@ -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 ( +
+ {data.map((v, i) => ( + + ))} +
+ ); +} + +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 = ({ searchQuery }) => { + const navigate = useNavigate(); const [stats, setStats] = useState(null); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); - const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); + const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); + const [newestLogId, setNewestLogId] = useState(null); + const [sparkTotal, setSparkTotal] = useState(() => Array(SPARK_LEN).fill(0)); + const [sparkAttackers, setSparkAttackers] = useState(() => Array(SPARK_LEN).fill(0)); + const [sparkBounties, setSparkBounties] = useState(() => Array(SPARK_LEN).fill(0)); + const lastStatsRef = useRef<{ total: number; uniq: number; bounties: number } | null>(null); const eventSourceRef = useRef(null); const reconnectTimerRef = useRef | 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 = ({ 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 = ({ 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(); + 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(); + 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
INITIALIZING SENSORS...
; + const sector = getSector(); + return (
+
+
+

DASHBOARD

+ SECTOR · {sector} · LIVE +
+
+ + LIVE + +
+
+ + {threat === 'critical' && ( +
+ + + ACTIVE BREACH — {hits5m} hits in last 5 min · {uniqueAttackers5m} attackers + + +
+ )} +
- } - label="TOTAL INTERACTIONS" - value={stats?.total_logs || 0} - /> - } - label="UNIQUE ATTACKERS" - value={stats?.unique_attackers || 0} - /> - } - label="ACTIVE DECKIES" - value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`} - /> +
+
+ TOTAL INTERACTIONS +
+
+
{(stats?.total_logs ?? 0).toLocaleString()}
+
+
+{hits5m} in last 5m
+ +
+
+ +
+
+ UNIQUE ATTACKERS +
+
+
{(stats?.unique_attackers ?? 0).toLocaleString()}
+
+
{uniqueAttackers5m} active in 5m
+ +
+
+ +
+
+ ACTIVE DECKIES +
+
+
+ {stats?.active_deckies ?? 0} + / {stats?.deployed_deckies ?? 0} +
+
+
OF TOTAL FLEET
+
+
+ +
+
+ BOUNTIES CAPTURED +
+
+
{bountiesCount.toLocaleString()}
+
+
THIS SESSION
+ +
+
-
-
- -

LIVE INTERACTION LOG

-
-
- - - - - - - - - - - - {logs.length > 0 ? logs.map(log => { - let parsedFields: Record = {}; - 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 ( - - - - - - - - ); - }) : ( +
+
+
+
+ + LIVE INTERACTION FEED + + LIVE + +
+
+ {logs.length} RECENT +
+
+
+
TIMESTAMPDECKYSERVICEATTACKER IPEVENT
{new Date(log.timestamp).toLocaleString()}{log.decky}{log.service}{log.attacker_ip} -
-
- {(() => { - const et = log.event_type && log.event_type !== '-' ? log.event_type : null; - const parts = [et, msgHead].filter(Boolean) as string[]; - return ( - <> - {parts.join(' · ')} - {msgTail && {parts.length ? ' — ' : ''}{msgTail}} - - ); - })()} -
- {(Object.keys(parsedFields).length > 0 || parsedFields.stored_as) && ( -
- {parsedFields.stored_as && ( - - )} - {Object.entries(parsedFields) - .filter(([k]) => k !== 'meta_json_b64') - .map(([k, v]) => ( - - {k}: {typeof v === 'object' ? JSON.stringify(v) : v} - - ))} -
- )} -
-
+ - + + + + + + - )} - -
NO INTERACTION DETECTEDTIMEDECKYSVCATTACKEREVENT
+ + + {logs.length > 0 ? logs.slice(0, 14).map(log => { + let parsedFields: Record = {}; + 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 ( + + + {new Date(log.timestamp).toLocaleTimeString()} + + + {log.is_bounty + ? BOUNTY + : } + + {log.decky} + {log.service} + {log.attacker_ip} + +
+
+ {(() => { + const et = log.event_type && log.event_type !== '-' ? log.event_type : null; + const parts = [et, msgHead].filter(Boolean) as string[]; + return ( + <> + {parts.join(' · ')} + {msgTail && ( + + {parts.length ? ' — ' : ''}{msgTail} + + )} + + ); + })()} +
+ {Object.keys(parsedFields).length > 0 && ( +
+ {parsedFields.stored_as != null && ( + + )} + {Object.entries(parsedFields) + .filter(([k]) => k !== 'meta_json_b64' && k !== 'stored_as') + .map(([k, v]) => ( + + {k}: + {typeof v === 'object' ? JSON.stringify(v) : String(v)} + + ))} +
+ )} +
+ + + ); + }) : ( + + NO INTERACTION DETECTED + + )} + + +
+
+ +
+
+
+
+ + DECKIES UNDER SIEGE +
+
+ {deckiesUnderSiege.length > 0 ? ( +
+ {deckiesUnderSiege.map(d => ( +
window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-decky', payload: d.name } }))} + > + + {d.name} +
+
+
+ {d.hits} +
+ ))} +
+ ) : ( +
NO ACTIVITY
+ )} +
+ +
+
+
+ + TOP ATTACKERS +
+
+ {topAttackers.length > 0 ? ( +
+ {topAttackers.map(a => ( +
window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-attacker', payload: a.ip } }))} + > + ?? + {a.ip} +
+
+
+ {a.hits} +
+ ))} +
+ ) : ( +
NO ATTACKERS YET
+ )} +
+ {artifact && ( } onClose={() => setArtifact(null)} /> )} @@ -226,20 +504,4 @@ const Dashboard: React.FC = ({ searchQuery }) => { ); }; -interface StatCardProps { - icon: React.ReactNode; - label: string; - value: string | number; -} - -const StatCard: React.FC = ({ icon, label, value }) => ( -
-
{icon}
-
- {label} - {value.toLocaleString()} -
-
-); - export default Dashboard;