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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}%
|
||||
|
||||
@@ -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' : ''}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
Reference in New Issue
Block a user