diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index cbf63554..4d9db2c7 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -19,7 +19,9 @@ const LiveLogs = lazy(() => import('./components/LiveLogs')); const Webhooks = lazy(() => import('./components/Webhooks')); const Attackers = lazy(() => import('./components/Attackers')); const AttackerDetail = lazy(() => import('./components/AttackerDetail')); +const Identities = lazy(() => import('./components/Identities')); const IdentityDetail = lazy(() => import('./components/IdentityDetail')); +const Campaigns = lazy(() => import('./components/Campaigns')); const CampaignDetail = lazy(() => import('./components/CampaignDetail')); const Config = lazy(() => import('./components/Config')); const Bounty = lazy(() => import('./components/Bounty')); @@ -115,7 +117,9 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/CampaignDetail.tsx b/decnet_web/src/components/CampaignDetail.tsx index 19c211a1..dc141586 100644 --- a/decnet_web/src/components/CampaignDetail.tsx +++ b/decnet_web/src/components/CampaignDetail.tsx @@ -1,18 +1,17 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Crosshair, Fingerprint, Globe, Radio } from '../icons'; +import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons'; import api from '../utils/api'; +import EmptyState from './EmptyState/EmptyState'; import { useCampaignStream } from './useCampaignStream'; import './Dashboard.css'; /* * CampaignDetail — read-only view of a campaign-clustered operation. * - * The layer above identity resolution. Member identities are visible - * here as rows that link back to IdentityDetail. Same visual vocabulary - * as IdentityDetail by design — the substrate (soft merges, schema - * version, JSON fingerprint summaries, live SSE updates) is identical - * one layer up. + * Layer above identity resolution. Member identities link back to + * IdentityDetail; same visual vocabulary as the rest of the app + * (page-header / sections / chips), no inline-style drift. */ interface CampaignData { @@ -97,26 +96,23 @@ const CampaignDetail: React.FC = () => { fetchIdentities(); }, [id]); - // Live updates: refetch when a campaign event references this uuid. + // Refetch when a campaign event references this uuid. useCampaignStream({ enabled: !!id, onEvent: (ev) => { if (!id) return; - const payload = ev.payload || {}; const refs = new Set(); const addUuid = (v: unknown) => { if (typeof v === 'string') refs.add(v); }; + const payload = ev.payload || {}; addUuid(payload.campaign_uuid); addUuid(payload.winner_uuid); addUuid(payload.loser_uuid); addUuid(payload.resurrected_uuid); addUuid(payload.former_winner_uuid); - if (refs.has(id)) { - api.get(`/campaigns/${id}`) - .then((res) => setCampaign(res.data)) - .catch(() => {}); + api.get(`/campaigns/${id}`).then((res) => setCampaign(res.data)).catch(() => {}); api.get(`/campaigns/${id}/identities?limit=50&offset=0`) .then((res) => { setIdentities(res.data.data ?? []); @@ -129,24 +125,20 @@ const CampaignDetail: React.FC = () => { if (loading) { return ( -
-
- LOADING CAMPAIGN… -
+
+
); } if (error || !campaign) { return ( -
- -
- {error || 'CAMPAIGN NOT FOUND'} -
+
); } @@ -157,122 +149,117 @@ const CampaignDetail: React.FC = () => { const c2List = safeParseJsonList(campaign.c2_endpoints); return ( -
- -
- -

- CAMPAIGN · {campaign.uuid} -

- {campaign.merged_into_uuid && ( - navigate(`/campaigns/${campaign.merged_into_uuid}`)} - > - MERGED INTO {campaign.merged_into_uuid.slice(0, 8)} - - )} -
- -
-
-
{campaign.identity_count_live}
-
IDENTITIES
-
-
-
{ja3List.length}
-
JA3
-
-
-
{hasshList.length}
-
HASSH
-
-
-
{payloadList.length}
-
PAYLOADS
-
-
-
{c2List.length}
-
C2 ENDPOINTS
-
-
- - {(campaign.confidence !== null || campaign.schema_version > 1) && ( -
- {campaign.confidence !== null && ( - - CONFIDENCE · {campaign.confidence.toFixed(3)} - - )} - - SCHEMA · v{campaign.schema_version} - -
- )} - - {ja3List.length > 0 && ( - } label="JA3" items={ja3List} /> - )} - {hasshList.length > 0 && ( - } label="HASSH" items={hasshList} /> - )} - {c2List.length > 0 && ( - } label="C2 ENDPOINTS" items={c2List} /> - )} - -
-
- -

- IDENTITIES · {identityTotal} -

-
- {identities.length === 0 ? ( -
- No identities linked yet. The campaign clusterer assigns - identities asynchronously; they should appear shortly after - the next clusterer pass. +
+
+
+ +

CAMPAIGN · {campaign.uuid.slice(0, 12)}…

+ {campaign.merged_into_uuid && ( + navigate(`/campaigns/${campaign.merged_into_uuid}`)} + title="Soft-merged. Click to view canonical winner." + > + MERGED → {campaign.merged_into_uuid.slice(0, 8)}… + + )}
- ) : ( - - - - - - - - - - - {identities.map((ident) => ( - navigate(`/identities/${ident.uuid}`)} - > - - - - + + {campaign.identity_count_live} IDENTITIES · + {' '}{ja3List.length} JA3 · {hasshList.length} HASSH · + {' '}{payloadList.length} PAYLOAD · {c2List.length} C2 + {campaign.confidence !== null && ( + <> · CONFIDENCE {campaign.confidence.toFixed(3)} + )} + {' '}· SCHEMA v{campaign.schema_version} + + + + + {(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && ( +
+
+
+ + AGGREGATED FINGERPRINTS +
+
+
+ {ja3List.length > 0 && ( + } label="JA3" items={ja3List} /> + )} + {hasshList.length > 0 && ( + } label="HASSH" items={hasshList} /> + )} + {c2List.length > 0 && ( + } label="C2 ENDPOINTS" items={c2List} /> + )} +
+
+ )} + +
+
+
+ + {identityTotal} IDENTITIES IN THIS CAMPAIGN +
+
+
+ {identities.length === 0 ? ( + + ) : ( +
IDENTITYFIRST SEENLAST SEENOBSERVATIONS
{ident.uuid.slice(0, 12)}…{ident.first_seen_at ?? '—'}{ident.last_seen_at ?? '—'}{ident.observation_count}
+ + + + + + - ))} - -
IDENTITYFIRST SEENLAST SEENOBSERVATIONS
- )} + + + {identities.map((ident) => ( + navigate(`/identities/${ident.uuid}`)} + > + + {ident.uuid.slice(0, 12)}… + + {ident.first_seen_at ?? '—'} + {ident.last_seen_at ?? '—'} + + {ident.observation_count} + + + ))} + + + )} +
{campaign.notes && ( -
-
- ANALYST NOTES +
+
+
+ ANALYST NOTES +
-
+
{campaign.notes}
@@ -281,32 +268,19 @@ const CampaignDetail: React.FC = () => { ); }; -const FingerprintList: React.FC<{ +const FingerprintGroup: React.FC<{ icon: React.ReactNode; label: string; items: string[]; }> = ({ icon, label, items }) => ( -
-
+
+
{icon} - - {label} - + {label}
-
+
{items.map((v) => ( - - {v} - + {v} ))}
diff --git a/decnet_web/src/components/Campaigns.tsx b/decnet_web/src/components/Campaigns.tsx new file mode 100644 index 00000000..7ec3393d --- /dev/null +++ b/decnet_web/src/components/Campaigns.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + ChevronLeft, ChevronRight, ChevronRight as ChevR, Filter, Crosshair, Search, +} from '../icons'; +import api from '../utils/api'; +import EmptyState from './EmptyState/EmptyState'; +import { useFocusSearch } from '../hooks/useFocusSearch'; +import { useCampaignStream } from './useCampaignStream'; +import './Dashboard.css'; + +interface CampaignEntry { + uuid: string; + schema_version: number; + first_seen_at: string | null; + last_seen_at: string | null; + updated_at: string; + confidence: number | null; + identity_count: number; + ja3_hashes: string | null; + hassh_hashes: string | null; + payload_simhashes: string | null; + c2_endpoints: string | null; + merged_into_uuid: string | null; +} + +const safeListLen = (raw: string | null): number => { + if (!raw) return 0; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.length : 0; + } catch { + return 0; + } +}; + +const timeAgo = (dateStr: string | null): string => { + if (!dateStr) return '—'; + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +}; + +const Campaigns: React.FC = () => { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const query = (searchParams.get('q') || '').toLowerCase(); + const page = parseInt(searchParams.get('page') || '1'); + + const [campaigns, setCampaigns] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(searchParams.get('q') || ''); + const searchRef = useRef(null); + useFocusSearch(searchRef); + + const limit = 50; + + const fetchCampaigns = async () => { + setLoading(true); + try { + const offset = (page - 1) * limit; + const res = await api.get(`/campaigns?limit=${limit}&offset=${offset}`); + setCampaigns(res.data.data ?? []); + setTotal(res.data.total ?? 0); + } catch (err) { + console.error('Failed to fetch campaigns', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchCampaigns(); }, [page]); + + useCampaignStream({ + enabled: true, + onEvent: () => { fetchCampaigns(); }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearchParams({ q: searchInput, page: '1' }); + }; + const setPage = (p: number) => setSearchParams({ q: searchParams.get('q') || '', page: p.toString() }); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + const visible = query + ? campaigns.filter((c) => c.uuid.toLowerCase().includes(query)) + : campaigns; + + const totalIdentities = campaigns.reduce((sum, c) => sum + c.identity_count, 0); + + return ( +
+
+
+
+ +

CAMPAIGN CLUSTERING

+
+ + {total.toLocaleString()} CAMPAIGNS · {totalIdentities} IDENTITIES GROUPED + +
+
+ +
+
+ + setSearchInput(e.target.value)} + /> +
+
+ +
+
+
+ + {visible.length.toLocaleString()} CAMPAIGNS SHOWN +
+
+
+ Page {page} of {totalPages} + + +
+
+
+ +
+ + + + + + + + + + + + + + + {visible.length > 0 ? visible.map((c) => ( + navigate(`/campaigns/${c.uuid}`)} + > + + + + + + + + + + )) : ( + + + + )} + +
UUIDFIRST SEENLAST SEENFINGERPRINTSINFRAIDENTITIESCONFIDENCE
+ {c.uuid.slice(0, 12)}… + {timeAgo(c.first_seen_at)}{timeAgo(c.last_seen_at)} + {safeListLen(c.ja3_hashes)} JA3{' '} + {safeListLen(c.hassh_hashes)} HASSH + + {safeListLen(c.payload_simhashes)} PAYLOAD{' '} + {safeListLen(c.c2_endpoints)} C2 + {c.identity_count} + {c.confidence !== null ? c.confidence.toFixed(2) : '—'} + + +
+ +
+
+
+
+ ); +}; + +export default Campaigns; diff --git a/decnet_web/src/components/Dashboard.css b/decnet_web/src/components/Dashboard.css index 0965d2e5..8b355c0a 100644 --- a/decnet_web/src/components/Dashboard.css +++ b/decnet_web/src/components/Dashboard.css @@ -528,3 +528,25 @@ .fp-card-body { padding: 12px 16px; } + +/* Identity / Campaign fingerprint groupings — used inside a logs-section */ +.fp-group { + margin-bottom: 12px; +} +.fp-group:last-child { margin-bottom: 0; } + +.fp-group-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.7rem; + letter-spacing: 2px; + opacity: 0.7; + margin-bottom: 6px; +} + +.fp-group-items { + display: flex; + flex-wrap: wrap; + gap: 6px; +} diff --git a/decnet_web/src/components/Identities.tsx b/decnet_web/src/components/Identities.tsx new file mode 100644 index 00000000..69a6f716 --- /dev/null +++ b/decnet_web/src/components/Identities.tsx @@ -0,0 +1,223 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + ChevronLeft, ChevronRight, ChevronRight as ChevR, Filter, Fingerprint, Search, +} from '../icons'; +import api from '../utils/api'; +import EmptyState from './EmptyState/EmptyState'; +import { useFocusSearch } from '../hooks/useFocusSearch'; +import { useIdentityStream } from './useIdentityStream'; +import './Dashboard.css'; + +interface IdentityEntry { + uuid: string; + schema_version: number; + campaign_id: string | null; + first_seen_at: string | null; + last_seen_at: string | null; + updated_at: string; + confidence: number | null; + observation_count: number; + ja3_hashes: string | null; + hassh_hashes: string | null; + payload_simhashes: string | null; + c2_endpoints: string | null; + merged_into_uuid: string | null; +} + +const safeListLen = (raw: string | null): number => { + if (!raw) return 0; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.length : 0; + } catch { + return 0; + } +}; + +const timeAgo = (dateStr: string | null): string => { + if (!dateStr) return '—'; + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +}; + +const Identities: React.FC = () => { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const query = (searchParams.get('q') || '').toLowerCase(); + const page = parseInt(searchParams.get('page') || '1'); + + const [identities, setIdentities] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(searchParams.get('q') || ''); + const searchRef = useRef(null); + useFocusSearch(searchRef); + + const limit = 50; + + const fetchIdentities = async () => { + setLoading(true); + try { + const offset = (page - 1) * limit; + const res = await api.get(`/identities?limit=${limit}&offset=${offset}`); + setIdentities(res.data.data ?? []); + setTotal(res.data.total ?? 0); + } catch (err) { + console.error('Failed to fetch identities', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchIdentities(); }, [page]); + + // Live updates: refetch on any clusterer event so the list stays + // current without polling. + useIdentityStream({ + enabled: true, + onEvent: () => { fetchIdentities(); }, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearchParams({ q: searchInput, page: '1' }); + }; + const setPage = (p: number) => setSearchParams({ q: searchParams.get('q') || '', page: p.toString() }); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + const visible = query + ? identities.filter((i) => + i.uuid.toLowerCase().includes(query) + || (i.campaign_id || '').toLowerCase().includes(query), + ) + : identities; + + const assignedCount = identities.filter((i) => i.campaign_id).length; + + return ( +
+
+
+
+ +

IDENTITY RESOLUTION

+
+ + {total.toLocaleString()} IDENTITIES · {assignedCount} CAMPAIGN-ASSIGNED + +
+
+ +
+
+ + setSearchInput(e.target.value)} + /> +
+
+ +
+
+
+ + {visible.length.toLocaleString()} IDENTITIES SHOWN +
+
+
+ Page {page} of {totalPages} + + +
+
+
+ +
+ + + + + + + + + + + + + + + {visible.length > 0 ? visible.map((i) => ( + navigate(`/identities/${i.uuid}`)} + > + + + + + + + + + + )) : ( + + + + )} + +
UUIDFIRST SEENLAST SEENJA3 / HASSHPAYLOADS / C2OBSCAMPAIGN
+ {i.uuid.slice(0, 12)}… + {timeAgo(i.first_seen_at)}{timeAgo(i.last_seen_at)} + {safeListLen(i.ja3_hashes)} JA3{' '} + {safeListLen(i.hassh_hashes)} HASSH + + {safeListLen(i.payload_simhashes)} PAYLOAD{' '} + {safeListLen(i.c2_endpoints)} C2 + {i.observation_count} + {i.campaign_id ? ( + { + e.stopPropagation(); + navigate(`/campaigns/${i.campaign_id}`); + }} + > + {i.campaign_id.slice(0, 8)}… + + ) : ( + + )} + + +
+ +
+
+
+
+ ); +}; + +export default Identities; diff --git a/decnet_web/src/components/IdentityDetail.tsx b/decnet_web/src/components/IdentityDetail.tsx index f3bd6a88..8a46fee7 100644 --- a/decnet_web/src/components/IdentityDetail.tsx +++ b/decnet_web/src/components/IdentityDetail.tsx @@ -1,24 +1,17 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Crosshair, Fingerprint, Globe, Radio } from '../icons'; +import { ArrowLeft, Crosshair, Filter, Fingerprint, Globe, Radio } from '../icons'; import api from '../utils/api'; +import EmptyState from './EmptyState/EmptyState'; import { useIdentityStream } from './useIdentityStream'; import './Dashboard.css'; /* * IdentityDetail — read-only view of a resolved attacker identity. * - * The clusterer worker that populates these rows is a separate - * downstream effort; until it ships, /identities/* responses are - * empty and this page renders the not-found state. See - * development/IDENTITY_RESOLUTION.md. - * - * The page is intentionally narrow at v1: header (uuid + campaign - * link if assigned), aggregated stats (observation count, fingerprint - * counts), and a list of linked observations that link back to - * AttackerDetail. Bigger surfaces (intel summary, kd_digraph_simhash - * neighbors, federation gossip status) ship after the clusterer - * lands and there's data to render. + * Header (page-header), aggregated stats in the sub-line, fingerprint + * groups in their own section, observations in a logs-section table. + * Same vocabulary as CampaignDetail one layer up. */ interface IdentityData { @@ -106,29 +99,22 @@ const IdentityDetail: React.FC = () => { fetchObservations(); }, [id]); - // Live updates: when the clusterer fires an identity event that - // touches this identity (links a fresh observation, soft-merges, - // resurrects on unmerge), refetch both the row and the observations - // list so the page reflects current truth without a manual refresh. useIdentityStream({ enabled: !!id, onEvent: (ev) => { if (!id) return; - const payload = ev.payload || {}; const refs = new Set(); const addUuid = (v: unknown) => { if (typeof v === 'string') refs.add(v); }; + const payload = ev.payload || {}; addUuid(payload.identity_uuid); addUuid(payload.winner_uuid); addUuid(payload.loser_uuid); addUuid(payload.resurrected_uuid); addUuid(payload.former_winner_uuid); - if (refs.has(id)) { - api.get(`/identities/${id}`) - .then((res) => setIdentity(res.data)) - .catch(() => {}); + api.get(`/identities/${id}`).then((res) => setIdentity(res.data)).catch(() => {}); api.get(`/identities/${id}/observations?limit=50&offset=0`) .then((res) => { setObservations(res.data.data ?? []); @@ -141,24 +127,20 @@ const IdentityDetail: React.FC = () => { if (loading) { return ( -
-
- LOADING IDENTITY… -
+
+
); } if (error || !identity) { return ( -
- -
- {error || 'IDENTITY NOT FOUND'} -
+
); } @@ -169,137 +151,125 @@ const IdentityDetail: React.FC = () => { const c2List = safeParseJsonList(identity.c2_endpoints); return ( -
- - {/* Header */} -
- -

- IDENTITY · {identity.uuid} -

- {identity.campaign_id && ( - navigate(`/campaigns/${identity.campaign_id}`)} - > - CAMPAIGN · {identity.campaign_id.slice(0, 8)} - - )} - {identity.merged_into_uuid && ( - navigate(`/identities/${identity.merged_into_uuid}`)} - > - MERGED INTO {identity.merged_into_uuid.slice(0, 8)} - - )} -
- - {/* Stats row */} -
-
-
{identity.observation_count_live}
-
OBSERVATIONS
-
-
-
{ja3List.length}
-
JA3
-
-
-
{hasshList.length}
-
HASSH
-
-
-
{payloadList.length}
-
PAYLOADS
-
-
-
{c2List.length}
-
C2 ENDPOINTS
-
-
- - {/* Confidence + schema version, only show if populated */} - {(identity.confidence !== null || identity.schema_version > 1) && ( -
- {identity.confidence !== null && ( - - CONFIDENCE · {identity.confidence.toFixed(3)} - - )} - - SCHEMA · v{identity.schema_version} - -
- )} - - {/* Fingerprint detail rows */} - {ja3List.length > 0 && ( - } label="JA3" items={ja3List} /> - )} - {hasshList.length > 0 && ( - } label="HASSH" items={hasshList} /> - )} - {c2List.length > 0 && ( - } label="C2 ENDPOINTS" items={c2List} /> - )} - - {/* Observations table */} -
-
- -

- OBSERVATIONS · {observationTotal} -

-
- {observations.length === 0 ? ( -
- No observations linked yet. The clusterer assigns observations - asynchronously; they should appear shortly after the next - clusterer pass. +
+
+
+ +

IDENTITY · {identity.uuid.slice(0, 12)}…

+ {identity.campaign_id && ( + navigate(`/campaigns/${identity.campaign_id}`)} + title="Campaign assignment from the campaign clusterer" + > + CAMPAIGN · {identity.campaign_id.slice(0, 8)}… + + )} + {identity.merged_into_uuid && ( + navigate(`/identities/${identity.merged_into_uuid}`)} + title="Soft-merged. Click to view canonical winner." + > + MERGED → {identity.merged_into_uuid.slice(0, 8)}… + + )}
- ) : ( - - - - - - - - - - - {observations.map((obs) => ( - navigate(`/attackers/${obs.uuid}`)} - > - - - - + + {identity.observation_count_live} OBSERVATIONS · + {' '}{ja3List.length} JA3 · {hasshList.length} HASSH · + {' '}{payloadList.length} PAYLOAD · {c2List.length} C2 + {identity.confidence !== null && ( + <> · CONFIDENCE {identity.confidence.toFixed(3)} + )} + {' '}· SCHEMA v{identity.schema_version} + + + + + {(ja3List.length > 0 || hasshList.length > 0 || c2List.length > 0) && ( +
+
+
+ + FINGERPRINTS +
+
+
+ {ja3List.length > 0 && ( + } label="JA3" items={ja3List} /> + )} + {hasshList.length > 0 && ( + } label="HASSH" items={hasshList} /> + )} + {c2List.length > 0 && ( + } label="C2 ENDPOINTS" items={c2List} /> + )} +
+
+ )} + +
+
+
+ + {observationTotal} OBSERVATIONS LINKED +
+
+
+ {observations.length === 0 ? ( + + ) : ( +
IPFIRST SEENLAST SEENEVENTS
{obs.ip}{obs.first_seen}{obs.last_seen}{obs.event_count}
+ + + + + + - ))} - -
IPFIRST SEENLAST SEENEVENTS
- )} + + + {observations.map((obs) => ( + navigate(`/attackers/${obs.uuid}`)} + > + {obs.ip} + {obs.first_seen} + {obs.last_seen} + + {obs.event_count} + + + ))} + + + )} +
{identity.notes && ( -
-
- ANALYST NOTES +
+
+
+ ANALYST NOTES +
-
+
{identity.notes}
@@ -308,32 +278,19 @@ const IdentityDetail: React.FC = () => { ); }; -const FingerprintList: React.FC<{ +const FingerprintGroup: React.FC<{ icon: React.ReactNode; label: string; items: string[]; }> = ({ icon, label, items }) => ( -
-
+
+
{icon} - - {label} - + {label}
-
+
{items.map((v) => ( - - {v} - + {v} ))}
diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index d1df614f..0984bcd6 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom'; import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, - ShieldAlert, Bell, Webhook, Lock, + ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, } from '../icons'; import { prefetchRoute } from '../routePrefetch'; import './Layout.css'; @@ -31,6 +31,8 @@ const ROUTE_LABELS: Record = { '/bounty': 'BOUNTY', '/credentials': 'CREDENTIALS', '/attackers': 'ATTACKERS', + '/identities': 'IDENTITIES', + '/campaigns': 'CAMPAIGNS', '/config': 'CONFIG', '/swarm-updates': 'REMOTE UPDATES', '/swarm/hosts': 'SWARM HOSTS', @@ -126,7 +128,11 @@ const Layout: React.FC = ({ } label="Bounty" open={sidebarOpen} /> } label="Credentials" open={sidebarOpen} /> - } label="Attackers" open={sidebarOpen} /> + } open={sidebarOpen}> + } label="Attackers" open={sidebarOpen} indent /> + } label="Identities" open={sidebarOpen} indent /> + } label="Campaigns" open={sidebarOpen} indent /> + } open={sidebarOpen}> } label="SWARM Hosts" open={sidebarOpen} indent /> } label="Remote Updates" open={sidebarOpen} indent />