From 1d739578328d8444cc7b68078fd0f075acbdc0f0 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 14 Apr 2026 13:42:52 -0400 Subject: [PATCH] feat: collapsible sections in attacker detail view All info sections (Timeline, Services, Deckies, Commands, Fingerprints) now have clickable headers with a chevron toggle to expand/collapse content. Pagination controls in Commands stay clickable without triggering the collapse. All sections default to open. --- decnet_web/src/components/AttackerDetail.tsx | 125 +++++++++++-------- 1 file changed, 76 insertions(+), 49 deletions(-) diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index 0007fdd..e50dc51 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, ChevronLeft, ChevronRight, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey } from 'lucide-react'; +import { ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey } from 'lucide-react'; import api from '../utils/api'; import './Dashboard.css'; @@ -312,6 +312,31 @@ const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ fpType, ); }; +// ─── Collapsible section ──────────────────────────────────────────────────── + +const Section: React.FC<{ + title: React.ReactNode; + right?: React.ReactNode; + open: boolean; + onToggle: () => void; + children: React.ReactNode; +}> = ({ title, right, open, onToggle, children }) => ( +
+
+
+ {open ? : } +

{title}

+
+ {right &&
e.stopPropagation()}>{right}
} +
+ {open && children} +
+); + // ─── Main component ───────────────────────────────────────────────────────── const AttackerDetail: React.FC = () => { @@ -322,6 +347,16 @@ const AttackerDetail: React.FC = () => { const [error, setError] = useState(null); const [serviceFilter, setServiceFilter] = useState(null); + // Section collapse state + const [openSections, setOpenSections] = useState>({ + timeline: true, + services: true, + deckies: true, + commands: true, + fingerprints: true, + }); + const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); + // Commands pagination state const [commands, setCommands] = useState([]); const [cmdTotal, setCmdTotal] = useState(0); @@ -441,10 +476,7 @@ const AttackerDetail: React.FC = () => { {/* Timestamps */} -
-
-

TIMELINE

-
+
toggle('timeline')}>
FIRST SEEN: @@ -459,13 +491,10 @@ const AttackerDetail: React.FC = () => { {new Date(attacker.updated_at).toLocaleString()}
-
+ {/* Services */} -
-
-

SERVICES TARGETED

-
+
toggle('services')}>
{attacker.services.length > 0 ? attacker.services.map((svc) => { const isActive = serviceFilter === svc; @@ -491,13 +520,10 @@ const AttackerDetail: React.FC = () => { No services recorded )}
-
+ {/* Deckies & Traversal */} -
-
-

DECKY INTERACTIONS

-
+
toggle('deckies')}>
{attacker.traversal_path ? (
@@ -515,39 +541,40 @@ const AttackerDetail: React.FC = () => {
)}
-
+ {/* Commands */} {(() => { const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit); return ( -
-
-

COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})

- {cmdTotalPages > 1 && ( -
- - Page {cmdPage} of {cmdTotalPages} - -
- - -
+
COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})} + open={openSections.commands} + onToggle={() => toggle('commands')} + right={openSections.commands && cmdTotalPages > 1 ? ( +
+ + Page {cmdPage} of {cmdTotalPages} + +
+ +
- )} -
+
+ ) : undefined} + > {commands.length > 0 ? (
@@ -578,7 +605,7 @@ const AttackerDetail: React.FC = () => { {serviceFilter ? `NO ${serviceFilter.toUpperCase()} COMMANDS CAPTURED` : 'NO COMMANDS CAPTURED'} )} - + ); })()} @@ -605,16 +632,16 @@ const AttackerDetail: React.FC = () => { const passiveTypes = ['ja3', 'ja4l', 'tls_resumption', 'tls_certificate', 'http_useragent', 'vnc_client_version']; const knownTypes = [...activeTypes, ...passiveTypes]; const unknownTypes = Object.keys(groups).filter((t) => !knownTypes.includes(t)); - const orderedTypes = [...activeTypes, ...passiveTypes, ...unknownTypes].filter((t) => groups[t]); const hasActive = activeTypes.some((t) => groups[t]); const hasPassive = [...passiveTypes, ...unknownTypes].some((t) => groups[t]); return ( -
-
-

FINGERPRINTS ({filteredFps.length}{serviceFilter ? ` / ${attacker.fingerprints.length}` : ''})

-
+
FINGERPRINTS ({filteredFps.length}{serviceFilter ? ` / ${attacker.fingerprints.length}` : ''})} + open={openSections.fingerprints} + onToggle={() => toggle('fingerprints')} + > {filteredFps.length > 0 ? (
{/* Active probes section */} @@ -652,7 +679,7 @@ const AttackerDetail: React.FC = () => { {serviceFilter ? `NO ${serviceFilter.toUpperCase()} FINGERPRINTS CAPTURED` : 'NO FINGERPRINTS CAPTURED'}
)} -
+ ); })()}