From e14527b382f670ca97114daa6c7827b5022d653d Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 22 Apr 2026 17:15:35 -0400 Subject: [PATCH] feat(web): reskin Attackers, Bounty, and LiveLogs pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each page gets its own scoped stylesheet and is rewritten around the shared design language: filter bars, paginated lists, empty-state blocks, BountyInspector drawer. Behavioural surface is unchanged — same API calls, same routes, same RBAC gating. --- decnet_web/src/components/Attackers.css | 178 +++++++ decnet_web/src/components/Attackers.tsx | 244 +++++---- decnet_web/src/components/Bounty.css | 238 +++++++++ decnet_web/src/components/Bounty.tsx | 353 ++++++------- decnet_web/src/components/BountyInspector.tsx | 100 ++++ decnet_web/src/components/LiveLogs.css | 182 +++++++ decnet_web/src/components/LiveLogs.tsx | 465 +++++++++--------- 7 files changed, 1184 insertions(+), 576 deletions(-) create mode 100644 decnet_web/src/components/Attackers.css create mode 100644 decnet_web/src/components/Bounty.css create mode 100644 decnet_web/src/components/BountyInspector.tsx create mode 100644 decnet_web/src/components/LiveLogs.css diff --git a/decnet_web/src/components/Attackers.css b/decnet_web/src/components/Attackers.css new file mode 100644 index 00000000..dc8bbc1e --- /dev/null +++ b/decnet_web/src/components/Attackers.css @@ -0,0 +1,178 @@ +.attackers-root { + display: flex; + flex-direction: column; + gap: 20px; +} + +.attackers-root .controls-row { + display: flex; + gap: 12px; + align-items: stretch; +} +.attackers-root .controls-row .search-container { flex: 1; max-width: none; } + +.attackers-root .sort-select { + background: var(--panel); + border: 1px solid var(--border); + color: var(--matrix); + padding: 8px 14px; + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 2px; + cursor: pointer; +} +.attackers-root .sort-select:focus { outline: none; border-color: var(--accent); } + +.attackers-root .service-filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.7rem; + padding: 2px 10px; + letter-spacing: 1px; + border: 1px solid var(--violet); + color: var(--violet); + background: var(--violet-tint-10); + cursor: pointer; +} + +/* Card grid — scoped to avoid colliding with global .attacker-card */ +.attackers-root .ak-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 14px; + padding: 16px; +} + +.attackers-root .ak-card { + background: var(--panel); + border: 1px solid var(--border); + padding: 16px; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + display: flex; + flex-direction: column; + gap: 10px; +} +.attackers-root .ak-card:hover { + transform: translateY(-2px); + border-color: var(--accent); + box-shadow: var(--accent-glow); +} + +.attackers-root .ak-card .ak-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.attackers-root .ak-card .ak-ip { + font-size: 1.05rem; + font-weight: 700; + color: var(--matrix); + font-variant-numeric: tabular-nums; + letter-spacing: 1px; +} + +.attackers-root .ak-meta { + display: flex; + gap: 14px; + font-size: 0.72rem; + opacity: 0.7; +} + +.attackers-root .ak-stats { + display: flex; + gap: 14px; + font-size: 0.78rem; +} +.attackers-root .ak-stats .n { font-weight: 700; } +.attackers-root .ak-stats .n.violet { color: var(--violet); } +.attackers-root .ak-stats .n.matrix { color: var(--matrix); } +.attackers-root .ak-stats .lbl { opacity: 0.55; font-size: 0.65rem; margin-right: 4px; letter-spacing: 1px; } + +.attackers-root .ak-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.attackers-root .ak-path { + font-size: 0.72rem; + opacity: 0.65; + word-break: break-all; +} +.attackers-root .ak-path .lbl { opacity: 0.5; margin-right: 6px; letter-spacing: 1px; font-size: 0.62rem; } + +.attackers-root .ak-lastcmd { + font-size: 0.7rem; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.attackers-root .ak-lastcmd .cmd { color: var(--matrix); } + +/* Activity chip */ +.attackers-root .activity-chip { + font-size: 0.62rem; + letter-spacing: 1.5px; + padding: 2px 8px; + border-radius: 3px; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.attackers-root .activity-chip .dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.attackers-root .activity-chip.active { + border: 1px solid var(--matrix); + color: var(--matrix); + background: var(--matrix-tint-10); +} +.attackers-root .activity-chip.active .dot { + background: var(--matrix); + box-shadow: 0 0 6px var(--matrix); + animation: decnet-pulse 1s infinite alternate; +} +.attackers-root .activity-chip.passive { + border: 1px solid rgba(238, 130, 238, 0.5); + color: var(--violet); + background: var(--violet-tint-10); +} +.attackers-root .activity-chip.passive .dot { background: var(--violet); } +.attackers-root .activity-chip.inactive { + border: 1px solid var(--border); + color: rgba(0, 255, 65, 0.5); + background: transparent; +} +.attackers-root .activity-chip.inactive .dot { background: var(--border); } + +/* Empty / loading */ +.attackers-root .empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 50px 20px; + opacity: 0.45; +} +.attackers-root .empty-state .type-label { font-size: 0.7rem; letter-spacing: 2px; } + +/* Pagination — same as Bounty */ +.attackers-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; } +.attackers-root .pager button { + padding: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--matrix); + display: flex; + cursor: pointer; +} +.attackers-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; } +.attackers-root .pager button:hover:not(:disabled) { border-color: var(--accent); } diff --git a/decnet_web/src/components/Attackers.tsx b/decnet_web/src/components/Attackers.tsx index 24e85774..81c67190 100644 --- a/decnet_web/src/components/Attackers.tsx +++ b/decnet_web/src/components/Attackers.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; -import { Crosshair, Search, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; +import { Search, ChevronLeft, ChevronRight, UserX } from 'lucide-react'; import api from '../utils/api'; import './Dashboard.css'; +import './Attackers.css'; interface AttackerEntry { uuid: string; @@ -23,6 +24,21 @@ interface AttackerEntry { updated_at: string; } +// Activity thresholds — tune here to adjust tier resolution. +const ACTIVE_MIN_EVENTS = 50; +const ACTIVE_MAX_AGE_MIN = 60; +const PASSIVE_MIN_EVENTS = 5; +const PASSIVE_MAX_AGE_HR = 24; + +type ActivityTier = 'active' | 'passive' | 'inactive'; + +function deriveActivity(a: AttackerEntry): ActivityTier { + const ageMin = (Date.now() - new Date(a.last_seen).getTime()) / 60000; + if (a.event_count >= ACTIVE_MIN_EVENTS && ageMin <= ACTIVE_MAX_AGE_MIN) return 'active'; + if (a.event_count >= PASSIVE_MIN_EVENTS && ageMin <= PASSIVE_MAX_AGE_HR * 60) return 'passive'; + return 'inactive'; +} + function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); @@ -56,7 +72,6 @@ const Attackers: React.FC = () => { let url = `/attackers?limit=${limit}&offset=${offset}&sort_by=${sortBy}`; if (query) url += `&search=${encodeURIComponent(query)}`; if (serviceFilter) url += `&service=${encodeURIComponent(serviceFilter)}`; - const res = await api.get(url); setAttackers(res.data.data); setTotal(res.data.total); @@ -67,9 +82,9 @@ const Attackers: React.FC = () => { } }; - useEffect(() => { - fetchAttackers(); - }, [query, sortBy, serviceFilter, page]); + useEffect(() => { fetchAttackers(); }, [query, sortBy, serviceFilter, page]); + + useEffect(() => { setSearchInput(query); }, [query]); const _params = (overrides: Record = {}) => { const base: Record = { q: query, sort_by: sortBy, service: serviceFilter, page: '1' }; @@ -80,178 +95,143 @@ const Attackers: React.FC = () => { e.preventDefault(); setSearchParams(_params({ q: searchInput })); }; + const setPage = (p: number) => setSearchParams(_params({ page: p.toString() })); + const setSort = (s: string) => setSearchParams(_params({ sort_by: s })); + const clearService = () => setSearchParams(_params({ service: '' })); - const setPage = (p: number) => { - setSearchParams(_params({ page: p.toString() })); - }; + const totalPages = Math.max(1, Math.ceil(total / limit)); - const setSort = (s: string) => { - setSearchParams(_params({ sort_by: s })); - }; - - const clearService = () => { - setSearchParams(_params({ service: '' })); - }; - - const totalPages = Math.ceil(total / limit); + const activityCounts = attackers.reduce( + (acc, a) => { acc[deriveActivity(a)]++; return acc; }, + { active: 0, passive: 0, inactive: 0 } as Record, + ); return ( -
- {/* Page Header */} -
-
- -

ATTACKER PROFILES

-
- -
-
- - -
- -
- - setSearchInput(e.target.value)} - style={{ background: 'transparent', border: 'none', padding: '4px', fontSize: '0.8rem', width: '200px' }} - /> - +
+
+
+

ATTACKERS

+ + {total.toLocaleString()} UNIQUE SOURCES · {activityCounts.active} ACTIVE · {activityCounts.passive} PASSIVE · {activityCounts.inactive} INACTIVE +
- {/* Summary & Pagination */} +
+
+ + setSearchInput(e.target.value)} + /> +
+ +
+
-
-
- {total} THREATS PROFILED +
+
+ SOURCE INTEL {serviceFilter && ( )}
- -
- - Page {page} of {totalPages || 1} - -
- -
- {/* Card Grid */} {loading ? ( -
- SCANNING THREAT PROFILES... +
+ SCANNING THREAT PROFILES...
) : attackers.length === 0 ? ( -
- NO ACTIVE THREATS PROFILED YET +
+ + NO ACTIVE THREATS PROFILED YET
) : ( -
- {attackers.map((a) => { - const lastCmd = a.commands.length > 0 - ? a.commands[a.commands.length - 1] - : null; - +
+ {attackers.map(a => { + const activity = deriveActivity(a); + const lastCmd = a.commands.length > 0 ? a.commands[a.commands.length - 1] : null; return (
navigate(`/attackers/${a.uuid}`)} > - {/* Header row */} -
- {a.ip} - {a.is_traversal && ( - TRAVERSAL - )} +
+ {a.ip} + + + {activity.toUpperCase()} +
- {/* Timestamps */} -
- First: {new Date(a.first_seen).toLocaleDateString()} - Last: {timeAgo(a.last_seen)} +
+ First: {new Date(a.first_seen).toLocaleDateString()} + Last: {timeAgo(a.last_seen)} + {a.is_traversal && TRAVERSAL}
- {/* Counts */} -
- Events: {a.event_count} - Bounties: {a.bounty_count} - Creds: {a.credential_count} +
+ EVENTS{a.event_count} + BOUNTIES{a.bounty_count} + CREDS{a.credential_count}
- {/* Services */} -
- {a.services.map((svc) => ( - { e.stopPropagation(); setSearchParams(_params({ service: svc })); }} - > - {svc.toUpperCase()} - - ))} -
+ {a.services.length > 0 && ( +
+ {a.services.map(svc => ( + { e.stopPropagation(); setSearchParams(_params({ service: svc })); }} + > + {svc.toUpperCase()} + + ))} +
+ )} - {/* Deckies / Traversal Path */} {a.traversal_path ? ( -
- Path: {a.traversal_path} -
+
PATH{a.traversal_path}
) : a.deckies.length > 0 ? ( -
- Deckies: {a.deckies.join(', ')} -
+
DECKIES{a.deckies.join(', ')}
) : null} - {/* Commands & Fingerprints */} -
- Cmds: {a.commands.length} - Fingerprints: {a.fingerprints.length} +
+ CMDS{a.commands.length} + FPS{a.fingerprints.length}
- {/* Last command preview */} {lastCmd && ( -
- Last cmd: {lastCmd.command} +
+ LAST + {lastCmd.command}
)}
diff --git a/decnet_web/src/components/Bounty.css b/decnet_web/src/components/Bounty.css new file mode 100644 index 00000000..c27bf6b2 --- /dev/null +++ b/decnet_web/src/components/Bounty.css @@ -0,0 +1,238 @@ +.bounty-root { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* Buttons scoped under root (mirrors DeckyFleet/LiveLogs pattern) */ +.bounty-root .btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 2px; + background: transparent; + border: 1px solid var(--matrix); + color: var(--matrix); + cursor: pointer; + transition: all 0.15s ease; +} +.bounty-root .btn:hover { background: var(--matrix); color: #000; box-shadow: var(--matrix-glow); } +.bounty-root .btn.violet { border-color: var(--violet); color: var(--violet); } +.bounty-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); } +.bounty-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; } +.bounty-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); } +.bounty-root .btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* Header controls */ +.bounty-root .controls-row { + display: flex; + gap: 12px; + align-items: stretch; +} +.bounty-root .controls-row .search-container { flex: 1; max-width: none; } + +/* Segmented type filter */ +.bounty-root .seg-group { + display: flex; + border: 1px solid var(--border); + background: var(--panel); +} +.bounty-root .seg-group button { + padding: 8px 14px; + font-size: 0.68rem; + letter-spacing: 1.5px; + border: none; + border-right: 1px solid var(--border); + background: transparent; + color: rgba(0, 255, 65, 0.6); + cursor: pointer; + font-family: inherit; +} +.bounty-root .seg-group button:last-child { border-right: none; } +.bounty-root .seg-group button.active { + background: var(--violet-tint-10); + color: var(--violet); +} +.bounty-root .seg-group button:hover:not(.active) { color: var(--matrix); } + +/* Table row interactivity */ +.bounty-root .logs-table tr.clickable { cursor: pointer; } +.bounty-root .logs-table tr.clickable:hover { background: rgba(238, 130, 238, 0.04); } +.bounty-root .logs-table td .attacker-link { + text-decoration: underline dotted; + cursor: pointer; +} +.bounty-root .logs-table td .data-preview { + font-size: 0.74rem; + opacity: 0.7; + max-width: 400px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bounty-root .logs-table td .cred-inline { + display: flex; + gap: 14px; + font-size: 0.8rem; +} +.bounty-root .logs-table td .cred-inline .k-small { + opacity: 0.6; + font-size: 0.65rem; + margin-right: 4px; +} +.bounty-root .logs-table td .cred-inline .matrix-text { color: var(--matrix); } + +/* Empty state */ +.bounty-root .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 50px 20px; + opacity: 0.45; +} +.bounty-root .empty-state .type-label { + font-size: 0.7rem; + letter-spacing: 2px; +} + +/* Pagination */ +.bounty-root .pager { display: flex; align-items: center; gap: 12px; font-size: 0.7rem; } +.bounty-root .pager button { + padding: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--matrix); + display: flex; + cursor: pointer; +} +.bounty-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; } +.bounty-root .pager button:hover:not(:disabled) { border-color: var(--accent); } + +/* ── Drawer ────────────────────────────────────────────── */ +.bounty-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: flex-end; + z-index: 1000; + animation: bd-fade 0.15s ease; +} +@keyframes bd-fade { from { opacity: 0; } to { opacity: 1; } } + +.bounty-drawer { + width: min(620px, 100%); + height: 100%; + background: var(--bg); + border-left: 1px solid var(--violet); + box-shadow: -12px 0 40px rgba(238, 130, 238, 0.1); + overflow-y: auto; + display: flex; + flex-direction: column; + animation: bd-slide 0.2s ease; +} +@keyframes bd-slide { from { transform: translateX(30px); opacity: 0.6; } to { transform: none; opacity: 1; } } + +.bounty-drawer .bd-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.bounty-drawer .bd-head h3 { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + letter-spacing: 3px; + color: var(--violet); + margin: 0; +} +.bounty-drawer .close-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + display: flex; + padding: 4px; + cursor: pointer; +} +.bounty-drawer .close-btn:hover { border-color: var(--accent); } + +.bounty-drawer .bd-body { + padding: 20px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.bounty-drawer .kvs { + display: grid; + grid-template-columns: 130px 1fr; + gap: 10px 12px; + font-size: 0.8rem; +} +.bounty-drawer .kvs .k { + opacity: 0.55; + font-size: 0.7rem; + letter-spacing: 1.5px; +} +.bounty-drawer .kvs .v { word-break: break-all; } +.bounty-drawer .kvs .attacker-link { + text-decoration: underline dotted; + cursor: pointer; + color: var(--matrix); +} +.bounty-drawer .violet-accent { color: var(--violet); } + +.bounty-drawer .type-label { + font-size: 0.68rem; + letter-spacing: 2px; + opacity: 0.6; + margin-bottom: 8px; +} + +.bounty-drawer .code-block { + background: var(--panel); + border: 1px solid var(--border); + border-left: 2px solid var(--violet); + padding: 12px 14px; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--matrix); + white-space: pre-wrap; + word-break: break-all; + margin: 0; + overflow-x: auto; +} +.bounty-drawer .code-block .ck { color: rgba(238, 130, 238, 0.9); } +.bounty-drawer .code-block .cs { color: var(--matrix); } + +.bounty-drawer .bd-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +/* Reuse .btn under drawer */ +.bounty-drawer .btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-family: inherit; + font-size: 0.68rem; + letter-spacing: 2px; + background: transparent; + border: 1px solid var(--border); + color: var(--matrix); + cursor: pointer; + transition: all 0.15s ease; + opacity: 0.8; +} +.bounty-drawer .btn.ghost:hover { opacity: 1; border-color: var(--matrix); box-shadow: var(--matrix-glow); } diff --git a/decnet_web/src/components/Bounty.tsx b/decnet_web/src/components/Bounty.tsx index 895918c4..f3aecfdd 100644 --- a/decnet_web/src/components/Bounty.tsx +++ b/decnet_web/src/components/Bounty.tsx @@ -1,8 +1,13 @@ import React, { useEffect, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { Archive, Search, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR, + ArchiveX, +} from 'lucide-react'; import api from '../utils/api'; +import BountyInspector from './BountyInspector'; import './Dashboard.css'; +import './Bounty.css'; interface BountyEntry { id: number; @@ -14,119 +19,34 @@ interface BountyEntry { payload: any; } -const _FINGERPRINT_LABELS: Record = { - fingerprint_type: 'TYPE', - ja3: 'JA3', - ja3s: 'JA3S', - ja4: 'JA4', - ja4s: 'JA4S', - ja4l: 'JA4L', - sni: 'SNI', - alpn: 'ALPN', - dst_port: 'PORT', - mechanisms: 'MECHANISM', - raw_ciphers: 'CIPHERS', - hash: 'HASH', - target_ip: 'TARGET', - target_port: 'PORT', - ssh_banner: 'BANNER', - kex_algorithms: 'KEX', - encryption_s2c: 'ENC (S→C)', - mac_s2c: 'MAC (S→C)', - compression_s2c: 'COMP (S→C)', - raw: 'RAW', - ttl: 'TTL', - window_size: 'WINDOW', - df_bit: 'DF', - mss: 'MSS', - window_scale: 'WSCALE', - sack_ok: 'SACK', - timestamp: 'TS', - options_order: 'OPTS ORDER', +const FINGERPRINT_LABELS: Record = { + fingerprint_type: 'TYPE', ja3: 'JA3', ja3s: 'JA3S', ja4: 'JA4', ja4s: 'JA4S', ja4l: 'JA4L', + sni: 'SNI', alpn: 'ALPN', dst_port: 'PORT', mechanisms: 'MECHANISM', raw_ciphers: 'CIPHERS', + hash: 'HASH', target_ip: 'TARGET', target_port: 'PORT', ssh_banner: 'BANNER', + kex_algorithms: 'KEX', encryption_s2c: 'ENC (S→C)', mac_s2c: 'MAC (S→C)', + compression_s2c: 'COMP (S→C)', raw: 'RAW', ttl: 'TTL', window_size: 'WINDOW', df_bit: 'DF', + mss: 'MSS', window_scale: 'WSCALE', sack_ok: 'SACK', timestamp: 'TS', options_order: 'OPTS ORDER', }; -const _TAG_STYLE: React.CSSProperties = { - fontSize: '0.65rem', - padding: '1px 6px', - borderRadius: '3px', - border: '1px solid rgba(238, 130, 238, 0.4)', - backgroundColor: 'rgba(238, 130, 238, 0.08)', - color: 'var(--accent-color)', - whiteSpace: 'nowrap', - flexShrink: 0, -}; - -const _HASH_STYLE: React.CSSProperties = { - fontSize: '0.75rem', - fontFamily: 'monospace', - opacity: 0.85, - wordBreak: 'break-all', -}; - -const FingerprintPayload: React.FC<{ payload: any }> = ({ payload }) => { +const FingerprintPreview: React.FC<{ payload: any }> = ({ payload }) => { if (!payload || typeof payload !== 'object') { - return {JSON.stringify(payload)}; + return {JSON.stringify(payload)}; } - - // For simple payloads like tls_resumption with just type + mechanism const keys = Object.keys(payload); - const isSimple = keys.length <= 3; - - if (isSimple) { - return ( -
- {keys.map((k) => { - const val = payload[k]; - if (val === null || val === undefined) return null; - const label = _FINGERPRINT_LABELS[k] || k.toUpperCase(); - return ( - - {label} - {String(val)} - - ); - })} -
- ); + const priority = ['fingerprint_type', 'ja3', 'ja4', 'hash', 'sni', 'target_ip', 'ssh_banner']; + const show = priority.filter(k => payload[k] !== undefined && payload[k] !== null).slice(0, 2); + if (!show.length) { + return {keys.slice(0, 3).join(', ')}; } - - // Full fingerprint — show priority fields as labeled rows - const priorityKeys = ['fingerprint_type', 'ja3', 'ja3s', 'ja4', 'ja4s', 'ja4l', 'sni', 'alpn', 'dst_port', 'mechanisms', 'hash', 'target_ip', 'target_port', 'ssh_banner', 'ttl', 'window_size', 'mss', 'options_order']; - const shown = priorityKeys.filter((k) => payload[k] !== undefined && payload[k] !== null); - const rest = keys.filter((k) => !priorityKeys.includes(k) && payload[k] !== null && payload[k] !== undefined); - return ( -
- {shown.map((k) => { - const label = _FINGERPRINT_LABELS[k] || k.toUpperCase(); - const val = String(payload[k]); - return ( -
- {label} - {val} -
- ); - })} - {rest.length > 0 && ( -
- - +{rest.length} MORE FIELDS - -
- {rest.map((k) => ( -
- {(_FINGERPRINT_LABELS[k] || k).toUpperCase()} - {String(payload[k])} -
- ))} -
-
- )} -
+ + {show.map(k => `${FINGERPRINT_LABELS[k] || k.toUpperCase()}: ${payload[k]}`).join(' · ')} + ); }; const Bounty: React.FC = () => { + const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get('q') || ''; const typeFilter = searchParams.get('type') || ''; @@ -136,7 +56,8 @@ const Bounty: React.FC = () => { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [searchInput, setSearchInput] = useState(query); - + const [selected, setSelected] = useState(null); + const limit = 50; const fetchBounties = async () => { @@ -146,7 +67,6 @@ const Bounty: React.FC = () => { let url = `/bounty?limit=${limit}&offset=${offset}`; if (query) url += `&search=${encodeURIComponent(query)}`; if (typeFilter) url += `&bounty_type=${typeFilter}`; - const res = await api.get(url); setBounties(res.data.data); setTotal(res.data.total); @@ -157,86 +77,80 @@ const Bounty: React.FC = () => { } }; - useEffect(() => { - fetchBounties(); - }, [query, typeFilter, page]); + useEffect(() => { fetchBounties(); }, [query, typeFilter, page]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); setSearchParams({ q: searchInput, type: typeFilter, page: '1' }); }; + const setPage = (p: number) => setSearchParams({ q: query, type: typeFilter, page: p.toString() }); + const setType = (t: string) => setSearchParams({ q: query, type: t, page: '1' }); - const setPage = (p: number) => { - setSearchParams({ q: query, type: typeFilter, page: p.toString() }); - }; + const totalPages = Math.max(1, Math.ceil(total / limit)); - const setType = (t: string) => { - setSearchParams({ q: query, type: t, page: '1' }); - }; + const credCount = bounties.filter(b => b.bounty_type === 'credential').length; + const payCount = bounties.filter(b => b.bounty_type === 'payload').length; + const fpCount = bounties.filter(b => b.bounty_type === 'fingerprint').length; - const totalPages = Math.ceil(total / limit); + const SEGMENTS: [string, string][] = [ + ['', 'ALL'], + ['credential', 'CREDENTIALS'], + ['payload', 'PAYLOADS'], + ['fingerprint', 'FINGERPRINTS'], + ]; return ( -
- {/* Page Header */} -
-
- -

BOUNTY VAULT

-
- -
-
- - +
+
+
+
+ +

BOUNTY VAULT

- -
- - setSearchInput(e.target.value)} - style={{ background: 'transparent', border: 'none', padding: '4px', fontSize: '0.8rem', width: '200px' }} - /> - + + {total.toLocaleString()} ARTIFACTS · {credCount} CREDENTIALS · {payCount} PAYLOADS · {fpCount} FINGERPRINTS +
+
+
+ + setSearchInput(e.target.value)} + /> +
+
+ {SEGMENTS.map(([v, l]) => ( + + ))} +
+
+
-
-
- {total} ARTIFACTS CAPTURED +
+
+ + {total.toLocaleString()} ARTIFACTS CAPTURED
- -
- - Page {page} of {totalPages || 1} - -
- -
@@ -246,52 +160,72 @@ const Bounty: React.FC = () => { - + - + + - {bounties.length > 0 ? bounties.map((b) => ( - - - - - - - setSelected(b)}> + + + + + + - - )) : ( + + + + ); + }) : ( - )} @@ -299,6 +233,17 @@ const Bounty: React.FC = () => {
TIMESTAMPTIME DECKYSERVICESVC ATTACKER TYPE DATA
{new Date(b.timestamp).toLocaleString()}{b.decky}{b.service}{b.attacker_ip} - - {b.bounty_type.toUpperCase()} - - -
- {b.bounty_type === 'credential' ? ( -
- user:{b.payload.username} - pass:{b.payload.password} + {bounties.length > 0 ? bounties.map(b => { + const isCred = b.bounty_type === 'credential'; + const isFp = b.bounty_type === 'fingerprint'; + const Icon = isCred ? Key : Package; + return ( +
+ {new Date(b.timestamp).toLocaleTimeString()} + {b.decky}{b.service} + { e.stopPropagation(); navigate(`/attackers?q=${encodeURIComponent(b.attacker_ip)}`); }} + > + {b.attacker_ip} + + + + + {b.bounty_type.toUpperCase()} + + + {isCred ? ( +
+ user:{b.payload?.username ?? '—'} + + pass: + {b.payload?.password ?? '—'} +
- ) : b.bounty_type === 'fingerprint' ? ( - + ) : isFp ? ( + ) : ( - {JSON.stringify(b.payload)} + + {b.payload?.query || b.payload?.body || b.payload?.command || JSON.stringify(b.payload)} + )} - -
+ +
- {loading ? 'RETRIEVING ARTIFACTS...' : 'THE VAULT IS EMPTY'} + +
+ + + {loading ? 'RETRIEVING ARTIFACTS...' : 'THE VAULT IS EMPTY'} + +
+ + {selected && ( + setSelected(null)} + onSelectAttacker={(ip) => { + setSelected(null); + navigate(`/attackers?q=${encodeURIComponent(ip)}`); + }} + /> + )}
); }; diff --git a/decnet_web/src/components/BountyInspector.tsx b/decnet_web/src/components/BountyInspector.tsx new file mode 100644 index 00000000..eb57f9aa --- /dev/null +++ b/decnet_web/src/components/BountyInspector.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { X, Key, Package, Copy, Send, Ban } from 'lucide-react'; +import { useToast } from './Toasts/useToast'; + +interface BountyEntry { + id: number; + timestamp: string; + decky: string; + service: string; + attacker_ip: string; + bounty_type: string; + payload: any; +} + +interface Props { + bounty: BountyEntry; + onClose: () => void; + onSelectAttacker: (ip: string) => void; +} + +const BountyInspector: React.FC = ({ bounty, onClose, onSelectAttacker }) => { + const { push } = useToast(); + const isCred = bounty.bounty_type === 'credential'; + const Icon = isCred ? Key : Package; + const p = bounty.payload || {}; + + const copyJson = async () => { + try { + await navigator.clipboard.writeText(JSON.stringify(bounty, null, 2)); + push({ text: 'JSON COPIED', tone: 'matrix', icon: 'copy' }); + } catch { + push({ text: 'CLIPBOARD BLOCKED', tone: 'alert', icon: 'alert-triangle' }); + } + }; + + const stubMisp = () => push({ text: 'MISP NOT CONFIGURED', tone: 'violet', icon: 'info' }); + const stubBlocklist = () => push({ text: 'BLOCKLIST NOT WIRED', tone: 'violet', icon: 'info' }); + + return ( +
+
e.stopPropagation()}> +
+

+ + ARTIFACT #{bounty.id} +

+ +
+
+
+
TYPE
+
+ {bounty.bounty_type.toUpperCase()} +
+
TIMESTAMP
+
{new Date(bounty.timestamp).toLocaleString()}
+
DECKY
+
{bounty.decky}
+
SERVICE
+
{bounty.service}
+
ATTACKER
+
+ onSelectAttacker(bounty.attacker_ip)} + > + {bounty.attacker_ip} + +
+
+ +
+
{isCred ? 'CAPTURED CREDENTIAL' : 'CAPTURED PAYLOAD'}
+ {isCred ? ( +
+                username: {p.username}{'\n'}
+                password: {p.password}
+              
+ ) : ( +
{JSON.stringify(p, null, 2)}
+ )} +
+ +
+
EXPORT
+
+ + + +
+
+
+
+
+ ); +}; + +export default BountyInspector; diff --git a/decnet_web/src/components/LiveLogs.css b/decnet_web/src/components/LiveLogs.css new file mode 100644 index 00000000..9752dd4d --- /dev/null +++ b/decnet_web/src/components/LiveLogs.css @@ -0,0 +1,182 @@ +.logs-root { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* Button system (mirrors DeckyFleet.css scoping) */ +.logs-root .btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 2px; + background: transparent; + border: 1px solid var(--matrix); + color: var(--matrix); + cursor: pointer; + transition: all 0.15s ease; +} +.logs-root .btn:hover { + background: var(--matrix); + color: #000; + box-shadow: var(--matrix-glow); +} +.logs-root .btn.violet { border-color: var(--violet); color: var(--violet); } +.logs-root .btn.violet:hover { background: var(--violet); color: #000; box-shadow: var(--violet-glow); } +.logs-root .btn.ghost { border-color: var(--border); color: var(--matrix); opacity: 0.7; } +.logs-root .btn.ghost:hover { opacity: 1; border-color: var(--matrix); background: transparent; box-shadow: var(--matrix-glow); } +.logs-root .btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* Control row */ +.logs-root .logs-controls { + display: flex; + gap: 12px; + align-items: stretch; +} +.logs-root .logs-controls .search-container { flex: 1; max-width: none; } +.logs-root .time-select { + background: var(--panel); + border: 1px solid var(--border); + color: var(--matrix); + padding: 8px 14px; + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 2px; + cursor: pointer; +} +.logs-root .time-select:focus { outline: none; border-color: var(--accent); } + +/* Histogram */ +.logs-root .histogram-wrap { + background: var(--panel); + border: 1px solid var(--border); + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 10px; +} +.logs-root .histogram-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.65rem; + letter-spacing: 1.5px; + opacity: 0.75; +} +.logs-root .histogram-header .violet-accent { + color: var(--violet); + opacity: 1; +} +.logs-root .histogram-header .clear-sel { + cursor: pointer; + text-decoration: underline; + margin-left: 4px; +} +.logs-root .histogram { + display: flex; + align-items: flex-end; + gap: 3px; + height: 80px; +} +.logs-root .histogram .bar { + flex: 1; + min-height: 2px; + background: var(--accent); + opacity: 0.4; + cursor: pointer; + transition: opacity 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} +.logs-root .histogram .bar:hover { opacity: 0.8; } +.logs-root .histogram .bar.selected { + background: var(--violet); + opacity: 1; + box-shadow: var(--violet-glow); +} +.logs-root .histogram .bar.has-bounty { background: #ffaa00; opacity: 0.7; } +.logs-root .histogram .bar.has-bounty.selected { background: var(--violet); } +.logs-root .histogram-axis { + display: flex; + justify-content: space-between; + font-size: 0.6rem; + opacity: 0.5; + letter-spacing: 1px; +} + +/* Table tweaks */ +.logs-root .logs-table td.t-time { font-size: 0.72rem; opacity: 0.55; white-space: nowrap; } +.logs-root .logs-table td.t-decky { color: var(--violet); } +.logs-root .logs-table td.t-svc { color: var(--matrix); } +.logs-root .logs-table td.t-event .event-head { + font-weight: 700; + color: var(--matrix); + font-size: 0.85rem; +} +.logs-root .logs-table td.t-event .event-tail { font-weight: normal; opacity: 0.75; } +.logs-root .logs-table td.t-event .badges { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; +} +.logs-root .field-badge { + font-size: 0.68rem; + padding: 2px 8px; + border-radius: 3px; + background: var(--matrix-tint-10); + border: 1px solid var(--matrix-tint-30); + word-break: break-all; +} +.logs-root .field-badge .k { opacity: 0.6; margin-right: 4px; } +.logs-root .artifact-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.68rem; + padding: 2px 8px; + border-radius: 3px; + background: rgba(255, 170, 0, 0.1); + border: 1px solid rgba(255, 170, 0, 0.5); + color: #ffaa00; + cursor: pointer; + font-family: inherit; +} +.logs-root .artifact-btn:hover { + background: rgba(255, 170, 0, 0.2); + box-shadow: 0 0 8px rgba(255, 170, 0, 0.4); +} + +/* Empty state */ +.logs-root .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px 20px; + opacity: 0.45; +} +.logs-root .empty-state .type-label { + font-size: 0.7rem; + letter-spacing: 2px; +} + +/* Pagination */ +.logs-root .pager { + display: flex; + align-items: center; + gap: 12px; + font-size: 0.7rem; +} +.logs-root .pager button { + padding: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--matrix); + display: flex; + cursor: pointer; +} +.logs-root .pager button:disabled { opacity: 0.3; cursor: not-allowed; } +.logs-root .pager button:hover:not(:disabled) { border-color: var(--accent); } diff --git a/decnet_web/src/components/LiveLogs.tsx b/decnet_web/src/components/LiveLogs.tsx index ae77db97..1c56be1a 100644 --- a/decnet_web/src/components/LiveLogs.tsx +++ b/decnet_web/src/components/LiveLogs.tsx @@ -1,16 +1,14 @@ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { - Terminal, Search, Activity, - ChevronLeft, ChevronRight, Play, Pause, Paperclip + Terminal, Search, BarChart3, ChevronLeft, ChevronRight, + Play, Pause, Paperclip, Download, SearchX, X as XIcon, } from 'lucide-react'; -import { - BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell -} from 'recharts'; import api from '../utils/api'; import { parseEventBody } from '../utils/parseEventBody'; import ArtifactDrawer from './ArtifactDrawer'; import './Dashboard.css'; +import './LiveLogs.css'; interface LogEntry { id: number; @@ -22,93 +20,64 @@ interface LogEntry { raw_line: string; fields: string; msg: string; + is_bounty?: boolean; } -interface HistogramData { - time: string; - count: number; -} +const LIMIT = 50; const LiveLogs: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); - - // URL-synced state + const query = searchParams.get('q') || ''; const timeRange = searchParams.get('time') || '1h'; const isLive = searchParams.get('live') !== 'false'; const page = parseInt(searchParams.get('page') || '1'); - // Local state const [logs, setLogs] = useState([]); - const [histogram, setHistogram] = useState([]); const [totalLogs, setTotalLogs] = useState(0); const [loading, setLoading] = useState(true); const [streaming, setStreaming] = useState(isLive); const [searchInput, setSearchInput] = useState(query); - - const eventSourceRef = useRef(null); - const limit = 50; + const [selectedHour, setSelectedHour] = useState(null); + + const eventSourceRef = useRef(null); - // Open artifact drawer when a log row with stored_as is clicked. const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); - // Sync search input if URL changes (e.g. back button) - useEffect(() => { - setSearchInput(query); - }, [query]); + useEffect(() => { setSearchInput(query); }, [query]); + + const startTimeParam = (): string | null => { + if (timeRange === 'all') return null; + const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0; + if (!minutes) return null; + return new Date(Date.now() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19); + }; const fetchData = async () => { - if (streaming) return; // Don't fetch historical if streaming - setLoading(true); try { - const offset = (page - 1) * limit; - let url = `/logs?limit=${limit}&offset=${offset}&search=${encodeURIComponent(query)}`; - - // Calculate time bounds for historical fetch - const now = new Date(); - let startTime: string | null = null; - if (timeRange !== 'all') { - const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0; - if (minutes > 0) { - startTime = new Date(now.getTime() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19); - url += `&start_time=${startTime}`; - } - } - + const offset = (page - 1) * LIMIT; + let url = `/logs?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(query)}`; + const startTime = startTimeParam(); + if (startTime) url += `&start_time=${startTime}`; const res = await api.get(url); setLogs(res.data.data); setTotalLogs(res.data.total); - - // Fetch histogram for historical view - let histUrl = `/logs/histogram?search=${encodeURIComponent(query)}`; - if (startTime) histUrl += `&start_time=${startTime}`; - const histRes = await api.get(histUrl); - setHistogram(histRes.data); - } catch (err) { - console.error('Failed to fetch historical logs', err); + console.error('Failed to fetch logs', err); } finally { setLoading(false); } }; const setupSSE = () => { - 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}&search=${encodeURIComponent(query)}`; - - if (timeRange !== 'all') { - const minutes = timeRange === '15m' ? 15 : timeRange === '1h' ? 60 : timeRange === '24h' ? 1440 : 0; - if (minutes > 0) { - const startTime = new Date(Date.now() - minutes * 60000).toISOString().replace('T', ' ').substring(0, 19); - url += `&start_time=${startTime}`; - } - } + const startTime = startTimeParam(); + if (startTime) url += `&start_time=${startTime}`; const es = new EventSource(url); eventSourceRef.current = es; @@ -118,8 +87,6 @@ const LiveLogs: React.FC = () => { const payload = JSON.parse(event.data); if (payload.type === 'logs') { setLogs(prev => [...payload.data, ...prev].slice(0, 500)); - } else if (payload.type === 'histogram') { - setHistogram(payload.data); } else if (payload.type === 'stats') { setTotalLogs(payload.data.total_logs); } @@ -128,29 +95,29 @@ const LiveLogs: React.FC = () => { } }; - es.onerror = () => { - console.error('SSE connection lost, reconnecting...'); - }; + es.onerror = () => console.error('SSE connection lost, reconnecting...'); }; + // Always seed with REST backlog on mount / filter changes. + useEffect(() => { + fetchData(); + }, [query, timeRange, page]); + + // SSE follows the streaming toggle independently. useEffect(() => { if (streaming) { setupSSE(); - setLoading(false); - } else { + } else if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } - fetchData(); - } - - return () => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - } }; - }, [query, timeRange, streaming, page]); + }, [streaming, query, timeRange]); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -171,135 +138,165 @@ const LiveLogs: React.FC = () => { setSearchParams({ q: query, time: timeRange, live: 'false', page: newPage.toString() }); }; + const buckets = useMemo(() => { + const b = Array.from({ length: 24 }, (_, i) => ({ i, count: 0, bounties: 0 })); + logs.forEach(l => { + const h = parseInt(String(l.timestamp).slice(11, 13), 10); + if (!isNaN(h) && h >= 0 && h < 24) { + b[h].count++; + if (l.is_bounty) b[h].bounties++; + } + }); + return b; + }, [logs]); + const maxBar = Math.max(...buckets.map(b => b.count), 1); + const peakHour = buckets.findIndex(b => b.count === maxBar); + + const filteredLogs = useMemo(() => { + if (selectedHour == null) return logs; + return logs.filter(l => parseInt(String(l.timestamp).slice(11, 13), 10) === selectedHour); + }, [logs, selectedHour]); + + const handleExport = () => { + const header = 'timestamp,decky,service,attacker_ip,event_type,msg'; + const rows = filteredLogs.map(l => + [l.timestamp, l.decky, l.service, l.attacker_ip, l.event_type, (l.msg || '').replace(/"/g, '""')] + .map(v => `"${v ?? ''}"`).join(',') + ); + const blob = new Blob([[header, ...rows].join('\n')], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `decnet-logs-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + const totalPages = Math.max(1, Math.ceil(totalLogs / LIMIT)); + return ( -
- {/* Control Bar */} -
-
-
- - setSearchInput(e.target.value)} - /> -
- - -
-
- - {/* Histogram Chart */} -
-
-
- ATTACK VOLUME OVER TIME -
-
- MATCHES: {totalLogs.toLocaleString()} -
+
+
+
+

LOGS

+ + {filteredLogs.length.toLocaleString()} SHOWN · {totalLogs.toLocaleString()} MATCHES · STREAM {streaming ? 'LIVE' : 'PAUSED'} + +
+
+ +
- - - - - Math.floor(val).toString()} - /> - - - {histogram.map((entry, index) => ( - h.count)) || 1)) * 0.4} /> - ))} - - -
- {/* Logs Table */} -
-
-
- -

LOG EXPLORER

-
- {!streaming && ( -
- - Page {page} of {Math.ceil(totalLogs / limit)} - -
- - -
-
+
+
+ + setSearchInput(e.target.value)} + /> + {searchInput && ( + )}
+ +
-
+
+
+
+ + ATTACK VOLUME — PAST 24 HOURS + {selectedHour != null && ( + + · {String(selectedHour).padStart(2, '0')}:00 SELECTED — + setSelectedHour(null)}>clear + + )} +
+ PEAK: {maxBar} @ HOUR {String(peakHour).padStart(2, '0')} +
+
+ {buckets.map(b => ( +
0 ? 'has-bounty' : ''}`} + style={{ height: `${(b.count / maxBar) * 100}%` }} + title={`${String(b.i).padStart(2, '0')}:00 — ${b.count} events${b.bounties ? `, ${b.bounties} bounties` : ''}`} + onClick={() => setSelectedHour(selectedHour === b.i ? null : b.i)} + /> + ))} +
+
+ 00:0006:0012:0018:0023:59 +
+
+ +
+
+
+ + LOG EXPLORER +
+
+ SHOWING {filteredLogs.length} OF {totalLogs.toLocaleString()} + {!streaming && ( +
+ Page {page} of {totalPages} + + +
+ )} +
+
+ +
- + - + - {logs.length > 0 ? logs.map(log => { + {filteredLogs.length > 0 ? filteredLogs.map(log => { let parsedFields: Record = {}; if (log.fields) { - try { - parsedFields = JSON.parse(log.fields); - } catch (e) {} + try { parsedFields = JSON.parse(log.fields); } catch { /* noop */ } } - let msgHead: string | null = null; let msgTail: string | null = null; if (Object.keys(parsedFields).length === 0) { @@ -310,75 +307,62 @@ const LiveLogs: React.FC = () => { } else if (log.msg && log.msg !== '-') { msgTail = log.msg; } + const et = log.event_type && log.event_type !== '-' ? log.event_type : null; + const headParts = [et, msgHead].filter(Boolean) as string[]; + const hasBadges = Object.keys(parsedFields).length > 0 || parsedFields.stored_as; return ( - - - + + + - ); }) : ( - )} @@ -386,6 +370,7 @@ const LiveLogs: React.FC = () => {
TIMESTAMPTIME DECKYSERVICESVC ATTACKER EVENT
{new Date(log.timestamp).toLocaleString()}{log.decky}{log.service}{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} - - ))} -
+
+
+ {headParts.join(' · ')} + {msgTail && ( + + {headParts.length ? ' — ' : ''}{msgTail} + )}
+ {hasBadges && ( +
+ {parsedFields.stored_as && ( + + )} + {Object.entries(parsedFields) + .filter(([k]) => k !== 'meta_json_b64' && k !== 'stored_as') + .map(([k, v]) => ( + + {k}: + {typeof v === 'object' ? JSON.stringify(v) : String(v)} + + ))} +
+ )}
- {loading ? 'RETRIEVING DATA...' : 'NO LOGS MATCHING CRITERIA'} + +
+ + + {loading ? 'RETRIEVING DATA...' : 'NO LOGS MATCHING CRITERIA'} + +
+ {artifact && (