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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 api from '../utils/api';
|
||||||
import './Dashboard.css';
|
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 }) => (
|
||||||
|
<div className="logs-section">
|
||||||
|
<div
|
||||||
|
className="section-header"
|
||||||
|
style={{ justifyContent: 'space-between', cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{open ? <ChevronUp size={16} className="dim" /> : <ChevronDown size={16} className="dim" />}
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
{right && <div onClick={(e) => e.stopPropagation()}>{right}</div>}
|
||||||
|
</div>
|
||||||
|
{open && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Main component ─────────────────────────────────────────────────────────
|
// ─── Main component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const AttackerDetail: React.FC = () => {
|
const AttackerDetail: React.FC = () => {
|
||||||
@@ -322,6 +347,16 @@ const AttackerDetail: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
const [serviceFilter, setServiceFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Section collapse state
|
||||||
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||||
|
timeline: true,
|
||||||
|
services: true,
|
||||||
|
deckies: true,
|
||||||
|
commands: true,
|
||||||
|
fingerprints: true,
|
||||||
|
});
|
||||||
|
const toggle = (key: string) => setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
// Commands pagination state
|
// Commands pagination state
|
||||||
const [commands, setCommands] = useState<AttackerData['commands']>([]);
|
const [commands, setCommands] = useState<AttackerData['commands']>([]);
|
||||||
const [cmdTotal, setCmdTotal] = useState(0);
|
const [cmdTotal, setCmdTotal] = useState(0);
|
||||||
@@ -441,10 +476,7 @@ const AttackerDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timestamps */}
|
{/* Timestamps */}
|
||||||
<div className="logs-section">
|
<Section title="TIMELINE" open={openSections.timeline} onToggle={() => toggle('timeline')}>
|
||||||
<div className="section-header">
|
|
||||||
<h2>TIMELINE</h2>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '16px', display: 'flex', gap: '32px', fontSize: '0.85rem' }}>
|
<div style={{ padding: '16px', display: 'flex', gap: '32px', fontSize: '0.85rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<span className="dim">FIRST SEEN: </span>
|
<span className="dim">FIRST SEEN: </span>
|
||||||
@@ -459,13 +491,10 @@ const AttackerDetail: React.FC = () => {
|
|||||||
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
|
<span className="dim">{new Date(attacker.updated_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<div className="logs-section">
|
<Section title="SERVICES TARGETED" open={openSections.services} onToggle={() => toggle('services')}>
|
||||||
<div className="section-header">
|
|
||||||
<h2>SERVICES TARGETED</h2>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ padding: '16px', display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
{attacker.services.length > 0 ? attacker.services.map((svc) => {
|
{attacker.services.length > 0 ? attacker.services.map((svc) => {
|
||||||
const isActive = serviceFilter === svc;
|
const isActive = serviceFilter === svc;
|
||||||
@@ -491,13 +520,10 @@ const AttackerDetail: React.FC = () => {
|
|||||||
<span className="dim">No services recorded</span>
|
<span className="dim">No services recorded</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* Deckies & Traversal */}
|
{/* Deckies & Traversal */}
|
||||||
<div className="logs-section">
|
<Section title="DECKY INTERACTIONS" open={openSections.deckies} onToggle={() => toggle('deckies')}>
|
||||||
<div className="section-header">
|
|
||||||
<h2>DECKY INTERACTIONS</h2>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '16px', fontSize: '0.85rem' }}>
|
<div style={{ padding: '16px', fontSize: '0.85rem' }}>
|
||||||
{attacker.traversal_path ? (
|
{attacker.traversal_path ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -515,16 +541,17 @@ const AttackerDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* Commands */}
|
{/* Commands */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);
|
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);
|
||||||
return (
|
return (
|
||||||
<div className="logs-section">
|
<Section
|
||||||
<div className="section-header" style={{ justifyContent: 'space-between' }}>
|
title={<>COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})</>}
|
||||||
<h2>COMMANDS ({cmdTotal}{serviceFilter ? ` ${serviceFilter.toUpperCase()}` : ''})</h2>
|
open={openSections.commands}
|
||||||
{cmdTotalPages > 1 && (
|
onToggle={() => toggle('commands')}
|
||||||
|
right={openSections.commands && cmdTotalPages > 1 ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
<span className="dim" style={{ fontSize: '0.8rem' }}>
|
<span className="dim" style={{ fontSize: '0.8rem' }}>
|
||||||
Page {cmdPage} of {cmdTotalPages}
|
Page {cmdPage} of {cmdTotalPages}
|
||||||
@@ -546,8 +573,8 @@ const AttackerDetail: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
</div>
|
>
|
||||||
{commands.length > 0 ? (
|
{commands.length > 0 ? (
|
||||||
<div className="logs-table-container">
|
<div className="logs-table-container">
|
||||||
<table className="logs-table">
|
<table className="logs-table">
|
||||||
@@ -578,7 +605,7 @@ const AttackerDetail: React.FC = () => {
|
|||||||
{serviceFilter ? `NO ${serviceFilter.toUpperCase()} COMMANDS CAPTURED` : 'NO COMMANDS CAPTURED'}
|
{serviceFilter ? `NO ${serviceFilter.toUpperCase()} COMMANDS CAPTURED` : 'NO COMMANDS CAPTURED'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
@@ -605,16 +632,16 @@ const AttackerDetail: React.FC = () => {
|
|||||||
const passiveTypes = ['ja3', 'ja4l', 'tls_resumption', 'tls_certificate', 'http_useragent', 'vnc_client_version'];
|
const passiveTypes = ['ja3', 'ja4l', 'tls_resumption', 'tls_certificate', 'http_useragent', 'vnc_client_version'];
|
||||||
const knownTypes = [...activeTypes, ...passiveTypes];
|
const knownTypes = [...activeTypes, ...passiveTypes];
|
||||||
const unknownTypes = Object.keys(groups).filter((t) => !knownTypes.includes(t));
|
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 hasActive = activeTypes.some((t) => groups[t]);
|
||||||
const hasPassive = [...passiveTypes, ...unknownTypes].some((t) => groups[t]);
|
const hasPassive = [...passiveTypes, ...unknownTypes].some((t) => groups[t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="logs-section">
|
<Section
|
||||||
<div className="section-header">
|
title={<>FINGERPRINTS ({filteredFps.length}{serviceFilter ? ` / ${attacker.fingerprints.length}` : ''})</>}
|
||||||
<h2>FINGERPRINTS ({filteredFps.length}{serviceFilter ? ` / ${attacker.fingerprints.length}` : ''})</h2>
|
open={openSections.fingerprints}
|
||||||
</div>
|
onToggle={() => toggle('fingerprints')}
|
||||||
|
>
|
||||||
{filteredFps.length > 0 ? (
|
{filteredFps.length > 0 ? (
|
||||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
{/* Active probes section */}
|
{/* Active probes section */}
|
||||||
@@ -652,7 +679,7 @@ const AttackerDetail: React.FC = () => {
|
|||||||
{serviceFilter ? `NO ${serviceFilter.toUpperCase()} FINGERPRINTS CAPTURED` : 'NO FINGERPRINTS CAPTURED'}
|
{serviceFilter ? `NO ${serviceFilter.toUpperCase()} FINGERPRINTS CAPTURED` : 'NO FINGERPRINTS CAPTURED'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user