fix(ui): follow-up polish — icons, dashboard bar, filter redesign, bounty/creds sort

- Dashboard: fix invisible bar at bottom of LIVE FEED by constraining
  max-height on the section instead of the inner container; same fix
  for side panels
- Page icons: add violet-accent icon beside h1 on all 9 missing pages
  (CanaryTokens, RealismConfig, SyntheticFiles, PersonaGeneration,
  Attackers, Webhooks, LiveLogs, Topologies, DecoyFleet)
- Attackers filter chips: replace ad-hoc chip buttons with seg-group
  tabs (ALL / ACTIVE N / PASSIVE N / INACTIVE N) matching Credential
  Vault style; country chips use same seg-group treatment
- Credential Vault: add sortable headers to REUSE tab (LAST SEEN,
  PRINCIPAL, KIND, TARGETS, ATTEMPTS); reuses same SortTh pattern
- Bounty: remove CREDENTIALS and PAYLOADS tabs; keep ALL, ARTIFACTS,
  FINGERPRINTS; add EMAIL (artifact subtype, filtered client-side)
This commit is contained in:
2026-04-30 00:20:25 -04:00
parent 9adee07d21
commit fbc9877ef2
13 changed files with 152 additions and 69 deletions

View File

@@ -7,20 +7,52 @@
.ak-filter-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 8px;
align-items: center;
}
.ak-filter-row .chip {
cursor: pointer;
transition: opacity 0.15s;
.attackers-root .seg-group {
display: flex;
border: 1px solid var(--border);
background: var(--panel);
}
.ak-filter-row button {
.attackers-root .seg-group button {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
font-size: 0.68rem;
letter-spacing: 1.5px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
color: rgba(0, 255, 65, 0.5);
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
.attackers-root .seg-group button:last-child { border-right: none; }
.attackers-root .seg-group button.active {
background: var(--violet-tint-10);
color: var(--violet);
}
.attackers-root .seg-group button:hover:not(.active) { color: var(--matrix); }
.ak-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.ak-dot-active { background: var(--alert); box-shadow: 0 0 5px var(--alert); }
.ak-dot-passive { background: #ffaa00; }
.ak-dot-inactive { background: rgba(0, 255, 65, 0.3); }
.attackers-root .controls-row {
display: flex;
gap: 12px;

View File

@@ -134,7 +134,10 @@ const Attackers: React.FC = () => {
<div className="attackers-root">
<div className="page-header">
<div className="page-title-group">
<h1>ATTACKERS</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Users size={22} className="violet-accent" />
<h1>ATTACKERS</h1>
</div>
<span className="page-sub">
{total.toLocaleString()} UNIQUE SOURCES · {activityCounts.active} ACTIVE · {activityCounts.passive} PASSIVE · {activityCounts.inactive} INACTIVE
</span>
@@ -160,39 +163,35 @@ const Attackers: React.FC = () => {
</form>
<div className="ak-filter-row">
{(['active', 'passive', 'inactive'] as ActivityTier[]).map(tier => (
<button
key={tier}
type="button"
className={`chip ${activityFilter === tier ? (tier === 'active' ? 'alert-chip' : tier === 'passive' ? 'violet' : 'matrix') : 'dim-chip'}`}
style={{ cursor: 'pointer' }}
onClick={() => setActivity(tier)}
>
<span className={`dot status-dot ${tier === 'active' ? 'hot' : tier === 'passive' ? 'warn' : ''}`} style={{ marginRight: 4 }} />
{tier.toUpperCase()} {activityCounts[tier] > 0 ? activityCounts[tier] : ''}
</button>
))}
{countries.length > 0 && <span className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1, opacity: 0.4, alignSelf: 'center' }}>|</span>}
{countries.map(cc => (
<button
key={cc}
type="button"
className={`chip ${countryFilter === cc ? 'violet' : 'dim-chip'}`}
style={{ cursor: 'pointer' }}
onClick={() => setCountry(cc)}
>
{cc}
</button>
))}
{(activityFilter || countryFilter || serviceFilter) && (
<button
type="button"
className="chip dim-chip"
style={{ cursor: 'pointer', opacity: 0.6 }}
onClick={() => setSearchParams(_params({ activity: '', country: '', service: '' }))}
>
CLEAR ×
<div className="seg-group" role="tablist">
<button type="button" className={!activityFilter ? 'active' : ''} onClick={() => setActivity('')}>
ALL
</button>
{(['active', 'passive', 'inactive'] as ActivityTier[]).map(tier => (
<button
key={tier}
type="button"
className={activityFilter === tier ? 'active' : ''}
onClick={() => setActivity(tier)}
>
<span className={`ak-dot ak-dot-${tier}`} />
{tier.toUpperCase()}{activityCounts[tier] > 0 ? ` ${activityCounts[tier]}` : ''}
</button>
))}
</div>
{countries.length > 0 && (
<div className="seg-group" role="tablist">
{countries.map(cc => (
<button
key={cc}
type="button"
className={countryFilter === cc ? 'active' : ''}
onClick={() => setCountry(cc)}
>
{cc}
</button>
))}
</div>
)}
</div>

View File

@@ -72,7 +72,8 @@ const Bounty: React.FC = () => {
const offset = (page - 1) * limit;
let url = `/bounty?limit=${limit}&offset=${offset}`;
if (query) url += `&search=${encodeURIComponent(query)}`;
if (typeFilter) url += `&bounty_type=${typeFilter}`;
const apiType = typeFilter === 'mail' ? 'artifact' : typeFilter;
if (apiType) url += `&bounty_type=${apiType}`;
const res = await api.get(url);
setBounties(res.data.data);
setTotal(res.data.total);
@@ -94,10 +95,9 @@ const Bounty: React.FC = () => {
const totalPages = Math.max(1, Math.ceil(total / limit));
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 artCount = bounties.filter(b => b.bounty_type === 'artifact').length;
const artCount = bounties.filter(b => b.bounty_type === 'artifact' && b.payload?.kind !== 'mail').length;
const mailCount = bounties.filter(b => b.bounty_type === 'artifact' && b.payload?.kind === 'mail').length;
const handleSortCol = (col: string) => {
if (sortCol === col) {
@@ -109,9 +109,14 @@ const Bounty: React.FC = () => {
}
};
const filteredBounties = useMemo(() => {
if (typeFilter !== 'mail') return bounties;
return bounties.filter(b => b.bounty_type === 'artifact' && b.payload?.kind === 'mail');
}, [bounties, typeFilter]);
const sortedBounties = useMemo(() => {
if (!sortCol) return bounties;
return [...bounties].sort((a, b) => {
if (!sortCol) return filteredBounties;
return [...filteredBounties].sort((a, b) => {
let av: string | number = '';
let bv: string | number = '';
if (sortCol === 'time') { av = a.timestamp; bv = b.timestamp; }
@@ -122,7 +127,7 @@ const Bounty: React.FC = () => {
const cmp = String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
}, [bounties, sortCol, sortDir]);
}, [filteredBounties, sortCol, sortDir]);
const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => (
<th
@@ -136,10 +141,9 @@ const Bounty: React.FC = () => {
const SEGMENTS: [string, string][] = [
['', 'ALL'],
['credential', 'CREDENTIALS'],
['payload', 'PAYLOADS'],
['artifact', 'ARTIFACTS'],
['fingerprint', 'FINGERPRINTS'],
['mail', 'EMAIL'],
];
const formatBytes = (n: any): string => {
@@ -159,7 +163,7 @@ const Bounty: React.FC = () => {
<h1>BOUNTY VAULT</h1>
</div>
<span className="page-sub">
{total.toLocaleString()} BOUNTIES · {credCount} CREDENTIALS · {payCount} PAYLOADS · {artCount} ARTIFACTS · {fpCount} FINGERPRINTS
{total.toLocaleString()} BOUNTIES · {artCount} ARTIFACTS · {fpCount} FINGERPRINTS · {mailCount} EMAIL
</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Plus, Upload, X, AlertTriangle, Search,
Plus, Upload, X, AlertTriangle, Search, Target,
} from '../icons';
import api from '../utils/api';
import { useEscapeKey } from '../hooks/useEscapeKey';
@@ -948,7 +948,10 @@ const CanaryTokens: React.FC = () => {
<div className="fleet-root canary-tokens-root" style={{ padding: '24px', color: 'var(--text-color)' }}>
<div className="page-header">
<div className="page-title-group">
<h1>CANARY TOKENS</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Target size={22} className="violet-accent" />
<h1>CANARY TOKENS</h1>
</div>
<span className="page-sub">
{tokens.length} TOKEN{tokens.length === 1 ? '' : 'S'} · {counts.planted} PLANTED · {counts.hits} TOTAL HIT{counts.hits === 1 ? '' : 'S'} · {blobs.length} UPLOADED BLOB{blobs.length === 1 ? '' : 'S'}
</span>

View File

@@ -170,6 +170,23 @@ const Credentials: React.FC = () => {
});
}, [creds, sortCol, sortDir]);
const sortedReuseRows = useMemo(() => {
if (!sortCol) return reuseRows;
return [...reuseRows].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 === 'principal') { av = a.principal ?? ''; bv = b.principal ?? ''; }
else if (sortCol === 'kind') { av = a.secret_kind; bv = b.secret_kind; }
else if (sortCol === 'targets') { av = a.target_count; bv = b.target_count; }
else if (sortCol === 'attempts') { 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;
});
}, [reuseRows, sortCol, sortDir]);
const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => (
<th
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
@@ -382,18 +399,18 @@ const Credentials: React.FC = () => {
<table className="logs-table">
<thead>
<tr>
<th>LAST SEEN</th>
<th>PRINCIPAL</th>
<th>KIND</th>
<th>TARGETS</th>
<th>ATTEMPTS</th>
<SortTh col="seen">LAST SEEN</SortTh>
<SortTh col="principal">PRINCIPAL</SortTh>
<SortTh col="kind">KIND</SortTh>
<SortTh col="targets">TARGETS</SortTh>
<SortTh col="attempts">ATTEMPTS</SortTh>
<th>DECKIES</th>
<th>SERVICES</th>
<th></th>
</tr>
</thead>
<tbody>
{reuseRows.length > 0 ? reuseRows.map(r => {
{sortedReuseRows.length > 0 ? sortedReuseRows.map(r => {
const isPlain = r.secret_kind === 'plaintext';
const moreDeckies = Math.max(0, r.deckies.length - 3);
const moreServices = Math.max(0, r.services.length - 3);

View File

@@ -264,10 +264,15 @@
min-height: 0;
}
.dash-grid > .logs-section {
max-height: 480px;
overflow: hidden;
}
.dash-grid > .logs-section .logs-table-container {
flex: 1;
max-height: 420px;
overflow-y: auto;
min-height: 0;
}
.dash-side {
@@ -281,11 +286,13 @@
flex: 1;
min-height: 0;
overflow: hidden;
max-height: 300px;
}
.dash-side > .logs-section .panel-body {
max-height: 260px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* Attacker/siege rows */

View File

@@ -1553,7 +1553,10 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
<div className="fleet-root">
<div className="page-header">
<div className="page-title-group">
<h1>DECOY FLEET</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Server size={22} className="violet-accent" />
<h1>DECOY FLEET</h1>
</div>
<span className="page-sub">
{deckies.length} DECKIES DEPLOYED · {counts.active + counts.hot} ACTIVE · {counts.hot} UNDER SIEGE
{deployMode && (

View File

@@ -182,7 +182,10 @@ const LiveLogs: React.FC = () => {
<div className="logs-root">
<div className="page-header">
<div className="page-title-group">
<h1>LOGS</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Terminal size={22} className="violet-accent" />
<h1>LOGS</h1>
</div>
<span className="page-sub">
{filteredLogs.length.toLocaleString()} SHOWN · {totalLogs.toLocaleString()} MATCHES · STREAM {streaming ? 'LIVE' : 'PAUSED'}
</span>

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download,
Mail, Plus, Pencil, Trash2, Check, AlertTriangle, Upload, Download, Sparkles,
} from '../icons';
import api from '../utils/api';
import { useToast } from './Toasts/useToast';
@@ -736,7 +736,10 @@ const PersonaGeneration: React.FC<PersonaGenerationProps> = ({ topologyId }) =>
<div className="fleet-root persona-gen-root">
<div className="page-header">
<div className="page-title-group">
<h1>{isTopology ? 'TOPOLOGY PERSONAS' : 'PERSONA GENERATION'}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Sparkles size={22} className="violet-accent" />
<h1>{isTopology ? 'TOPOLOGY PERSONAS' : 'PERSONA GENERATION'}</h1>
</div>
<span className="page-sub">
{personas.length} PERSONA{personas.length === 1 ? '' : 'S'} · {llmHeavyCount} LLM-HEAVY
{isTopology

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import api from '../../utils/api';
import { useToast } from '../Toasts/useToast';
import { Save, RotateCcw, AlertTriangle } from '../../icons';
import { Save, RotateCcw, AlertTriangle, Sliders } from '../../icons';
import { contentClassLabel, isCanaryClass } from '../../realism/labels';
// Reuse the DeckyFleet shell (page-header / btn / fleet-* / dim / mono) and
// the persona-page tweaks (info-banner, .input) so the realism config panel
@@ -168,7 +168,10 @@ const RealismConfig: React.FC = () => {
<div className="fleet-root realism-config-root">
<div className="page-header">
<div className="page-title-group">
<h1>REALISM CONFIG</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Sliders size={22} className="violet-accent" />
<h1>REALISM CONFIG</h1>
</div>
<span className="page-sub">
USER {totals.user} · SYSTEM {totals.system} · CANARY {totals.canary} ·
{' '}CANARY PROB {(config.canary_probability * 100).toFixed(1)}%

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import api from '../../utils/api';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { X } from '../../icons';
import { X, FileText } from '../../icons';
import { contentClassLabel, isCanaryClass } from '../../realism/labels';
// Reuse the DeckyFleet shell + the persona-page tweaks so this page reads
// the same as the rest of the realism nav group.
@@ -241,7 +241,10 @@ const SyntheticFiles: React.FC = () => {
<div className="fleet-root synthetic-files-root">
<div className="page-header">
<div className="page-title-group">
<h1>SYNTHETIC FILES</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<FileText size={22} className="violet-accent" />
<h1>SYNTHETIC FILES</h1>
</div>
<span className="page-sub">
{total} TOTAL · PAGE {page + 1} / {totalPages}
{filtersActive ? ' · FILTERED' : ''}

View File

@@ -154,7 +154,10 @@ const TopologyList: React.FC = () => {
<div className="tlist-root tlist-page">
<div className="page-header">
<div className="page-title-group">
<h1>TOPOLOGIES</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Network size={22} className="violet-accent" />
<h1>TOPOLOGIES</h1>
</div>
<span className="page-sub">
{loading ? 'LOADING…' : `${rows.length} ${rows.length === 1 ? 'TOPOLOGY' : 'TOPOLOGIES'}`}
{err && <span className="alert-text"> · {err}</span>}

View File

@@ -292,7 +292,10 @@ const Webhooks: React.FC = () => {
<div className="webhooks-root">
<div className="page-header">
<div className="page-title-group">
<h1>WEBHOOKS</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<WebhookIcon size={22} className="violet-accent" />
<h1>WEBHOOKS</h1>
</div>
<span className="page-sub">
{webhooks.length} CONFIGURED · {enabledCount} ENABLED
{trippedCount > 0 && ` · ${trippedCount} TRIPPED`}