From 9adee07d211472c5f5fc912d2b99cf809c558d62 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 23:56:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20frontend=20polish=20sweep=20?= =?UTF-8?q?=E2=80=94=208=20UX=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeckyFleet: card click opens inspect side-drawer instead of auto-filtering (localSearch filter behavior removed) - Dashboard: LIVE FEED / DECKIES UNDER SIEGE / TOP ATTACKERS panels now have fixed max-height with overflow scroll instead of growing - parseEventBody: defensive RFC 5424 header strip so raw syslog lines from the collector render as k=v pills instead of raw text - Attackers: search placeholder updated; activity (Active/Passive/ Inactive) and country chip filters added on top of existing IP search - Credentials + Bounty: sortable column headers (click to asc/desc/clear) - SwarmHosts + RemoteUpdates: icon extracted from

into flex div with violet-accent class, matching site-wide Identities pattern - Swarm.css: fix --panel-border undefined variable → --border so the title border-bottom line is visible on SwarmHosts and RemoteUpdates --- decnet_web/src/components/Attackers.css | 19 ++- decnet_web/src/components/Attackers.tsx | 58 +++++++- decnet_web/src/components/Bounty.tsx | 51 ++++++- decnet_web/src/components/Credentials.tsx | 57 ++++++-- decnet_web/src/components/Dashboard.css | 10 +- decnet_web/src/components/DeckyFleet.tsx | 141 ++++++++++++++++++-- decnet_web/src/components/RemoteUpdates.tsx | 5 +- decnet_web/src/components/Swarm.css | 2 +- decnet_web/src/components/SwarmHosts.tsx | 5 +- decnet_web/src/utils/parseEventBody.ts | 7 +- 10 files changed, 318 insertions(+), 37 deletions(-) diff --git a/decnet_web/src/components/Attackers.css b/decnet_web/src/components/Attackers.css index aeb60fbb..1d5e5756 100644 --- a/decnet_web/src/components/Attackers.css +++ b/decnet_web/src/components/Attackers.css @@ -1,7 +1,24 @@ .attackers-root { display: flex; flex-direction: column; - gap: 20px; + gap: 16px; +} + +.ak-filter-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.ak-filter-row .chip { + cursor: pointer; + transition: opacity 0.15s; +} + +.ak-filter-row button { + font-family: inherit; + font-size: inherit; } .attackers-root .controls-row { diff --git a/decnet_web/src/components/Attackers.tsx b/decnet_web/src/components/Attackers.tsx index c83f0d01..926009d5 100644 --- a/decnet_web/src/components/Attackers.tsx +++ b/decnet_web/src/components/Attackers.tsx @@ -63,6 +63,8 @@ const Attackers: React.FC = () => { const query = searchParams.get('q') || ''; const sortBy = searchParams.get('sort_by') || 'recent'; const serviceFilter = searchParams.get('service') || ''; + const activityFilter = searchParams.get('activity') || ''; + const countryFilter = searchParams.get('country') || ''; const page = parseInt(searchParams.get('page') || '1'); const [attackers, setAttackers] = useState([]); @@ -96,7 +98,10 @@ const Attackers: React.FC = () => { useEffect(() => { setSearchInput(query); }, [query]); const _params = (overrides: Record = {}) => { - const base: Record = { q: query, sort_by: sortBy, service: serviceFilter, page: '1' }; + const base: Record = { + q: query, sort_by: sortBy, service: serviceFilter, + activity: activityFilter, country: countryFilter, page: '1', + }; return Object.fromEntries(Object.entries({ ...base, ...overrides }).filter(([, v]) => v !== '')); }; @@ -107,6 +112,8 @@ const Attackers: React.FC = () => { const setPage = (p: number) => setSearchParams(_params({ page: p.toString() })); const setSort = (s: string) => setSearchParams(_params({ sort_by: s })); const clearService = () => setSearchParams(_params({ service: '' })); + const setActivity = (a: string) => setSearchParams(_params({ activity: activityFilter === a ? '' : a })); + const setCountry = (c: string) => setSearchParams(_params({ country: countryFilter === c ? '' : c })); const totalPages = Math.max(1, Math.ceil(total / limit)); @@ -115,6 +122,14 @@ const Attackers: React.FC = () => { { active: 0, passive: 0, inactive: 0 } as Record, ); + const countries = [...new Set(attackers.map(a => a.country_code).filter(Boolean))].sort() as string[]; + + const visibleAttackers = attackers.filter(a => { + if (activityFilter && deriveActivity(a) !== activityFilter) return false; + if (countryFilter && a.country_code !== countryFilter) return false; + return true; + }); + return (
@@ -132,7 +147,7 @@ const Attackers: React.FC = () => { setSearchInput(e.target.value)} /> @@ -144,6 +159,43 @@ const Attackers: React.FC = () => { +
+ {(['active', 'passive', 'inactive'] as ActivityTier[]).map(tier => ( + + ))} + {countries.length > 0 && |} + {countries.map(cc => ( + + ))} + {(activityFilter || countryFilter || serviceFilter) && ( + + )} +
+
@@ -182,7 +234,7 @@ const Attackers: React.FC = () => { /> ) : (
- {attackers.map(a => { + {visibleAttackers.map(a => { const activity = deriveActivity(a); const lastCmd = a.commands.length > 0 ? a.commands[a.commands.length - 1] : null; return ( diff --git a/decnet_web/src/components/Bounty.tsx b/decnet_web/src/components/Bounty.tsx index 15bf6767..3877ca6c 100644 --- a/decnet_web/src/components/Bounty.tsx +++ b/decnet_web/src/components/Bounty.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR, @@ -61,6 +61,8 @@ const Bounty: React.FC = () => { const searchRef = useRef(null); useFocusSearch(searchRef); const [selected, setSelected] = useState(null); + const [sortCol, setSortCol] = useState(''); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const limit = 50; @@ -97,6 +99,41 @@ const Bounty: React.FC = () => { const fpCount = bounties.filter(b => b.bounty_type === 'fingerprint').length; const artCount = bounties.filter(b => b.bounty_type === 'artifact').length; + const handleSortCol = (col: string) => { + if (sortCol === col) { + if (sortDir === 'asc') setSortDir('desc'); + else { setSortCol(''); setSortDir('asc'); } + } else { + setSortCol(col); + setSortDir('asc'); + } + }; + + const sortedBounties = useMemo(() => { + if (!sortCol) return bounties; + return [...bounties].sort((a, b) => { + let av: string | number = ''; + let bv: string | number = ''; + if (sortCol === 'time') { av = a.timestamp; bv = b.timestamp; } + else if (sortCol === 'decky') { av = a.decky; bv = b.decky; } + else if (sortCol === 'svc') { av = a.service; bv = b.service; } + else if (sortCol === 'attacker') { av = a.attacker_ip; bv = b.attacker_ip; } + else if (sortCol === 'type') { av = a.bounty_type; bv = b.bounty_type; } + const cmp = String(av).localeCompare(String(bv)); + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [bounties, sortCol, sortDir]); + + const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => ( + handleSortCol(col)} + > + {children} + {sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''} + + ); + const SEGMENTS: [string, string][] = [ ['', 'ALL'], ['credential', 'CREDENTIALS'], @@ -175,17 +212,17 @@ const Bounty: React.FC = () => { - - - - - + TIME + DECKY + SVC + ATTACKER + TYPE - {bounties.length > 0 ? bounties.map(b => { + {sortedBounties.length > 0 ? sortedBounties.map(b => { const isCred = b.bounty_type === 'credential'; const isFp = b.bounty_type === 'fingerprint'; const isArt = b.bounty_type === 'artifact'; diff --git a/decnet_web/src/components/Credentials.tsx b/decnet_web/src/components/Credentials.tsx index d88f9aa8..ea451ee6 100644 --- a/decnet_web/src/components/Credentials.tsx +++ b/decnet_web/src/components/Credentials.tsx @@ -46,6 +46,8 @@ const Credentials: React.FC = () => { const [selectedCred, setSelectedCred] = useState(null); const [selectedReuse, setSelectedReuse] = useState(null); const [refreshTick, setRefreshTick] = useState(0); + const [sortCol, setSortCol] = useState(''); + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); // ── Fetch credentials (CREDS tab + always for badge totals) useEffect(() => { @@ -139,6 +141,45 @@ const Credentials: React.FC = () => { const plaintextCount = creds.filter(c => c.secret_kind === 'plaintext').length; const hashedCount = creds.length - plaintextCount; + const handleSortCol = (col: string) => { + if (sortCol === col) { + if (sortDir === 'asc') setSortDir('desc'); + else { setSortCol(''); setSortDir('asc'); } + } else { + setSortCol(col); + setSortDir('asc'); + } + }; + + const sortedCreds = useMemo(() => { + if (!sortCol) return creds; + return [...creds].sort((a, b) => { + let av: string | number = ''; + let bv: string | number = ''; + if (sortCol === 'seen') { av = a.last_seen; bv = b.last_seen; } + else if (sortCol === 'decky') { av = a.decky_name; bv = b.decky_name; } + else if (sortCol === 'svc') { av = a.service; bv = b.service; } + else if (sortCol === 'attacker') { av = a.attacker_ip; bv = b.attacker_ip; } + else if (sortCol === 'principal') { av = a.principal ?? ''; bv = b.principal ?? ''; } + else if (sortCol === 'kind') { av = a.secret_kind; bv = b.secret_kind; } + else if (sortCol === 'hits') { av = a.attempt_count; bv = b.attempt_count; } + const cmp = typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av).localeCompare(String(bv)); + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [creds, sortCol, sortDir]); + + const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => ( + + ); + const openReuseFromCred = async (key: string) => { const hit = reuseMap.get(key); if (!hit) return; @@ -248,20 +289,20 @@ const Credentials: React.FC = () => {
TIMEDECKYSVCATTACKERTYPE DATA
handleSortCol(col)} + > + {children} + {sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''} +
- - - - - + LAST SEEN + DECKY + SVC + ATTACKER + PRINCIPAL - - + KIND + HITS - {creds.length > 0 ? creds.map(c => { + {sortedCreds.length > 0 ? sortedCreds.map(c => { const isPlain = c.secret_kind === 'plaintext'; const secretText = isPlain ? (c.secret_printable ?? '—') diff --git a/decnet_web/src/components/Dashboard.css b/decnet_web/src/components/Dashboard.css index 76d744f4..fdb5f015 100644 --- a/decnet_web/src/components/Dashboard.css +++ b/decnet_web/src/components/Dashboard.css @@ -266,7 +266,8 @@ .dash-grid > .logs-section .logs-table-container { flex: 1; - max-height: none; + max-height: 420px; + overflow-y: auto; } .dash-side { @@ -278,6 +279,13 @@ .dash-side > .logs-section { flex: 1; + min-height: 0; + overflow: hidden; +} + +.dash-side > .logs-section .panel-body { + max-height: 260px; + overflow-y: auto; } /* Attacker/siege rows */ diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index 76a63e37..f9d168d0 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -3,6 +3,7 @@ import { Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff, RefreshCw, Server, Shield, Terminal, Plus, X, } from '../icons'; +import { useEscapeKey } from '../hooks/useEscapeKey'; import api from '../utils/api'; import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data'; import { useToast } from './Toasts/useToast'; @@ -125,6 +126,124 @@ const _stateColor = (state: string): string => { } }; +// ─── Decky inspect panel ───────────────────────────────────────────────── + +interface DeckyInspectPanelProps { + decky: Decky; + onClose: () => void; +} + +const DeckyInspectPanel: React.FC = ({ decky, onClose }) => { + useEscapeKey(onClose, true); + const status = _dotFor(decky); + + useEffect(() => { + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, []); + + const fmtDate = (ts: number | string | null | undefined) => { + if (!ts) return '—'; + const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts); + return isNaN(d.getTime()) ? String(ts) : d.toLocaleString(); + }; + + return ( +
+
e.stopPropagation()} + style={{ + width: 360, + background: 'var(--secondary-color)', + borderLeft: '1px solid var(--border)', + display: 'flex', flexDirection: 'column', + height: '100%', + overflowY: 'auto', + }} + > +
+
+ + + {decky.name} + +
+ +
+ +
+
+ {[ + ['IP', decky.ip], + ['HOSTNAME', decky.hostname], + ['DISTRO', decky.distro], + ['ARCHETYPE', decky.archetype], + ['LAST MUTATED', fmtDate(decky.last_mutated)], + ['MUTATE INTERVAL', decky.mutate_interval != null ? `${decky.mutate_interval}s` : '—'], + ].map(([label, val]) => val ? ( +
+ {label} + {val} +
+ ) : null)} +
+ + {decky.services.length > 0 && ( +
+
SERVICES
+
+ {decky.services.map(svc => ( + {svc} + ))} +
+
+ )} + + {decky.swarm && ( +
+
SWARM
+ {[ + ['HOST', decky.swarm.host_name], + ['ADDRESS', decky.swarm.host_address], + ['STATE', decky.swarm.state], + ['LAST SEEN', fmtDate(decky.swarm.last_seen)], + ['ERROR', decky.swarm.last_error], + ].map(([label, val]) => val ? ( +
+ {label} + {val} +
+ ) : null)} +
+ )} +
+
+
+ ); +}; + // ─── Decky card ─────────────────────────────────────────────────────────── interface DeckyCardProps { @@ -1180,6 +1299,7 @@ const DeckyFleet: React.FC = ({ searchQuery = '' }) => { const [archetypes, setArchetypes] = useState(FALLBACK_ARCHETYPES); const [localSearch, setLocalSearch] = useState(''); const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null); + const [selectedDecky, setSelectedDecky] = useState(null); const cardRefs = useRef>(new Map()); const lastSearchPropRef = useRef(searchQuery); @@ -1362,9 +1482,7 @@ const DeckyFleet: React.FC = ({ searchQuery = '' }) => { }; const handleInspect = (d: Decky) => { - window.dispatchEvent(new CustomEvent('decnet:cmd', { - detail: { id: 'filter-decky', payload: d.name }, - })); + setSelectedDecky(d); }; useEffect(() => { @@ -1394,16 +1512,6 @@ const DeckyFleet: React.FC = ({ searchQuery = '' }) => { void handleMutateAll(); return; } - if (detail.id === 'filter-decky' && typeof detail.payload === 'string') { - const name = detail.payload; - setLocalSearch(name); - push({ text: `FILTERING · ${name.toUpperCase()}`, tone: 'violet', icon: 'crosshair' }); - // Defer so React renders filtered grid first. - window.setTimeout(() => { - const el = cardRefs.current.get(name); - if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); - }, 80); - } }; window.addEventListener('decnet:cmd', onCmd); return () => window.removeEventListener('decnet:cmd', onCmd); @@ -1549,6 +1657,13 @@ const DeckyFleet: React.FC = ({ searchQuery = '' }) => { onClose={() => setIntervalEditor(null)} onSave={handleIntervalSave} /> + + {selectedDecky && ( + setSelectedDecky(null)} + /> + )} ); }; diff --git a/decnet_web/src/components/RemoteUpdates.tsx b/decnet_web/src/components/RemoteUpdates.tsx index 17a8ee9f..bde108b6 100644 --- a/decnet_web/src/components/RemoteUpdates.tsx +++ b/decnet_web/src/components/RemoteUpdates.tsx @@ -169,7 +169,10 @@ const RemoteUpdates: React.FC = () => {
-

REMOTE UPDATES

+
+ +

REMOTE UPDATES

+
push updater bundles to enrolled workers · {hosts.length} WORKER{hosts.length === 1 ? '' : 'S'} diff --git a/decnet_web/src/components/Swarm.css b/decnet_web/src/components/Swarm.css index aa6cc263..6daee15f 100644 --- a/decnet_web/src/components/Swarm.css +++ b/decnet_web/src/components/Swarm.css @@ -5,7 +5,7 @@ gap: 24px; padding-bottom: 16px; margin-bottom: 24px; - border-bottom: 1px solid var(--panel-border); + border-bottom: 1px solid var(--border); } .swarm-root .page-title-group { diff --git a/decnet_web/src/components/SwarmHosts.tsx b/decnet_web/src/components/SwarmHosts.tsx index 272de317..524a4643 100644 --- a/decnet_web/src/components/SwarmHosts.tsx +++ b/decnet_web/src/components/SwarmHosts.tsx @@ -413,7 +413,10 @@ const SwarmHosts: React.FC = () => {
-

SWARM HOSTS

+
+ +

SWARM HOSTS

+
{loading ? 'LOADING…' : `${hosts.length} ENROLLED · ${online} ONLINE`} diff --git a/decnet_web/src/utils/parseEventBody.ts b/decnet_web/src/utils/parseEventBody.ts index 1a0e90a6..196a44b4 100644 --- a/decnet_web/src/utils/parseEventBody.ts +++ b/decnet_web/src/utils/parseEventBody.ts @@ -11,11 +11,16 @@ export interface ParsedBody { tail: string | null; } +// RFC 5424: VERSION TIMESTAMP HOSTNAME APPNAME PROCID MSGID SD MSG +// Matches both framed (<135>1 ...) and unframed (bare timestamp) variants. +const rfc5424Re = /^(?:<\d+>\d\s+)?\d{4}-\d{2}-\d{2}T[\d:.+-]+\s+\S+\s+\S+\s+\S+\s+\S+\s+(?:-|\[.*?\])\s*/; + export function parseEventBody(msg: string | null | undefined): ParsedBody { const empty: ParsedBody = { head: null, fields: {}, tail: null }; if (!msg) return empty; - const body = msg.trim(); + let body = msg.trim(); if (!body || body === '-') return empty; + body = body.replace(rfc5424Re, ''); const keyRe = /([A-Za-z_][A-Za-z0-9_]*)=/g; const firstKv = body.search(/(^|\s)[A-Za-z_][A-Za-z0-9_]*=/);
LAST SEENDECKYSVCATTACKERPRINCIPAL SECRETKINDHITS REUSE