feat(frontend): wireframes for 5 MVP screens + audit (F0.3)
Mock-data wireframes covering spec §5 / §9 surface area. All read from
src/mocks/fixtures.ts — no backend wiring yet. Each screen is built from
the design-system primitives (Panel, Pill, Button, label-system, status-dot)
and adheres to the instrumentation-grade visual grammar.
Screens:
- /login LoginPage — RT vs SOC mode switch (segmented), role-tinted.
RT form picks rt_operator / rt_lead at sign-in (mock only).
SOC form takes a session token (out-of-band, D-006).
Left rail carries mission brief + platform telemetry.
- /engagements EngagementsPage — mission roster table (codename, client,
status, c2_type, operators, SOC count, window).
- /runs LiveCockpitPage — the cornerstone screen. 3-column layout:
steps timeline | step detail (resolved command,
output, evidence, detection) | side rail
(DetectionPanel for SOC; EvidencePanel +
DetectionPanel readonly + CleanupPanel for RT).
Control bar (F6 pause/skip/retry/abort) is lead-RT-only.
Stats header: steps done, detected/partial/missed counts.
- /scenarios ScenarioComposerPage — 3-column composer:
filterable TTP library | ordered steps with delays
| inspector (params from params_schema_json, target
host list, jinja2 cleanup template preview).
c2_type locked at scenario level (D-F3 / H33).
- /library TtpLibraryPage — catalog table with stealth-variant
flagging, source provenance (custom/atr/mission),
payload_type chip, tags. Import journal / ATR buttons.
- /reports ReportPage — restricted MITRE matrix (techniques
played only, H29), narration timeline, integrity
hash footer (SHA-256, H19/H24/F9). PDF/JSON/MD
export buttons.
- /audit AuditPage — append-only journal viewer (lead RT only,
F13). Tabular timestamp/actor/role/action/resource.
UX guardrails baked in:
- SOC analysts never see RT-only controls (conditional rendering, not just
disabled state). UI layer mirrors backend RBAC but does not replace it.
- Layout density and dark-first palette tuned for long purple sessions
(sober contrast, no flash, status colors carry information without
being shouted).
- Live cockpit reserves a clear visual slot for cleanup-failed alerts
(R-T5) — currently a Pill, real alert UX lands when the WebSocket is
wired in sprint 1+.
This commit is contained in:
182
frontend/src/mocks/fixtures.ts
Normal file
182
frontend/src/mocks/fixtures.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Sprint 0 fixtures — purely static, no API call.
|
||||
* Shape is suggestive, not authoritative; final shape lands when the backend
|
||||
* publishes OpenAPI in sprint 1+.
|
||||
*/
|
||||
|
||||
export interface MockEngagement {
|
||||
id: string;
|
||||
client: string;
|
||||
codename: string;
|
||||
status: 'planning' | 'active' | 'reporting' | 'archived';
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
c2Type: 'mythic' | 'home';
|
||||
operators: number;
|
||||
socAnalysts: number;
|
||||
}
|
||||
|
||||
export interface MockTtp {
|
||||
id: string;
|
||||
technique: string;
|
||||
subtechnique?: string;
|
||||
name: string;
|
||||
payloadType: 'cmd' | 'powershell' | 'bof' | 'dotnet_assembly' | 'pe_exe' | 'shellcode';
|
||||
source: 'custom' | 'atr' | 'mission';
|
||||
tags: string[];
|
||||
stealth?: 'variant' | null;
|
||||
}
|
||||
|
||||
export interface MockHost {
|
||||
id: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
os: string;
|
||||
c2Session: string;
|
||||
status: 'live' | 'idle' | 'lost';
|
||||
}
|
||||
|
||||
export interface MockRunStep {
|
||||
id: string;
|
||||
orderIdx: number;
|
||||
ttpName: string;
|
||||
technique: string;
|
||||
host: string;
|
||||
payloadType: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed' | 'aborted' | 'skipped';
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
exitCode?: number;
|
||||
output?: string;
|
||||
detection?: {
|
||||
level: 'detected' | 'partial' | 'missed';
|
||||
source: 'edr' | 'ndr' | 'siem' | 'manual';
|
||||
latencyMs: number;
|
||||
by: string;
|
||||
};
|
||||
evidence?: {
|
||||
status: 'success' | 'partial' | 'failed';
|
||||
note: string;
|
||||
by: string;
|
||||
};
|
||||
cleanup?: {
|
||||
status: 'pending' | 'success' | 'failed' | 'partial';
|
||||
};
|
||||
}
|
||||
|
||||
export const MOCK_ENGAGEMENTS: MockEngagement[] = [
|
||||
{
|
||||
id: 'eng_42',
|
||||
client: 'Démo Client X',
|
||||
codename: 'OPERATION RUSTED ANCHOR',
|
||||
status: 'active',
|
||||
startDate: '2026-05-18',
|
||||
endDate: '2026-05-30',
|
||||
c2Type: 'mythic',
|
||||
operators: 4,
|
||||
socAnalysts: 3,
|
||||
},
|
||||
{
|
||||
id: 'eng_41',
|
||||
client: 'Banque Régionale Y',
|
||||
codename: 'OPERATION PALE TIDE',
|
||||
status: 'reporting',
|
||||
startDate: '2026-04-22',
|
||||
endDate: '2026-05-12',
|
||||
c2Type: 'home',
|
||||
operators: 4,
|
||||
socAnalysts: 2,
|
||||
},
|
||||
{
|
||||
id: 'eng_38',
|
||||
client: 'Cabinet Z',
|
||||
codename: 'OPERATION LONG SHADOW',
|
||||
status: 'archived',
|
||||
startDate: '2026-02-10',
|
||||
endDate: '2026-03-04',
|
||||
c2Type: 'mythic',
|
||||
operators: 2,
|
||||
socAnalysts: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_HOSTS: MockHost[] = [
|
||||
{ id: 'h_01', hostname: 'WIN-EXEC-04', ip: '10.42.1.18', os: 'Windows 11 22H2', c2Session: 'cbk-7a31', status: 'live' },
|
||||
{ id: 'h_02', hostname: 'SRV-DC-01', ip: '10.42.0.5', os: 'Windows Server 2022', c2Session: 'cbk-7a32', status: 'live' },
|
||||
{ id: 'h_03', hostname: 'WIN-DEV-12', ip: '10.42.4.66', os: 'Windows 10 22H2', c2Session: 'cbk-7a33', status: 'idle' },
|
||||
];
|
||||
|
||||
export const MOCK_TTPS: MockTtp[] = [
|
||||
{ id: 't_001', technique: 'T1059.001', name: 'PowerShell Encoded Command', payloadType: 'powershell', source: 'mission', tags: ['execution', 'fileless'], stealth: 'variant' },
|
||||
{ id: 't_002', technique: 'T1003.001', name: 'LSASS Memory Dump (BOF)', payloadType: 'bof', source: 'custom', tags: ['credential-access'], stealth: 'variant' },
|
||||
{ id: 't_003', technique: 'T1055', name: 'Process Injection via APC', payloadType: 'shellcode', source: 'custom', tags: ['defense-evasion'] },
|
||||
{ id: 't_004', technique: 'T1070.004', name: 'Indicator Removal — File Deletion', payloadType: 'cmd', source: 'atr', tags: ['defense-evasion'] },
|
||||
{ id: 't_005', technique: 'T1135', name: 'Network Share Discovery', payloadType: 'cmd', source: 'atr', tags: ['discovery'] },
|
||||
{ id: 't_006', technique: 'T1218.011', name: 'Rundll32 Execution', payloadType: 'cmd', source: 'mission', tags: ['defense-evasion'], stealth: 'variant' },
|
||||
{ id: 't_007', technique: 'T1547.001', name: 'Registry Run Key Persistence', payloadType: 'cmd', source: 'atr', tags: ['persistence'] },
|
||||
{ id: 't_008', technique: 'T1033', name: 'System Owner Discovery', payloadType: 'cmd', source: 'atr', tags: ['discovery'] },
|
||||
];
|
||||
|
||||
export const MOCK_RUN_STEPS: MockRunStep[] = [
|
||||
{
|
||||
id: 's_01',
|
||||
orderIdx: 1,
|
||||
ttpName: 'System Owner Discovery',
|
||||
technique: 'T1033',
|
||||
host: 'WIN-EXEC-04',
|
||||
payloadType: 'cmd',
|
||||
status: 'success',
|
||||
startedAt: '14:02:11',
|
||||
endedAt: '14:02:12',
|
||||
exitCode: 0,
|
||||
output: 'mimic\\j.doe',
|
||||
detection: { level: 'detected', source: 'edr', latencyMs: 1840, by: 'soc.alice' },
|
||||
evidence: { status: 'success', note: 'whoami returned domain user, identity confirmed.', by: 'rt.mickey' },
|
||||
cleanup: { status: 'success' },
|
||||
},
|
||||
{
|
||||
id: 's_02',
|
||||
orderIdx: 2,
|
||||
ttpName: 'Network Share Discovery',
|
||||
technique: 'T1135',
|
||||
host: 'WIN-EXEC-04',
|
||||
payloadType: 'cmd',
|
||||
status: 'success',
|
||||
startedAt: '14:02:18',
|
||||
endedAt: '14:02:20',
|
||||
exitCode: 0,
|
||||
output: '\\\\SRV-FILE-01\\Public\n\\\\SRV-FILE-01\\Backup',
|
||||
detection: { level: 'partial', source: 'siem', latencyMs: 14200, by: 'soc.alice' },
|
||||
evidence: { status: 'success', note: 'Two shares enumerated.', by: 'rt.mickey' },
|
||||
cleanup: { status: 'success' },
|
||||
},
|
||||
{
|
||||
id: 's_03',
|
||||
orderIdx: 3,
|
||||
ttpName: 'PowerShell Encoded Command',
|
||||
technique: 'T1059.001',
|
||||
host: 'WIN-EXEC-04',
|
||||
payloadType: 'powershell',
|
||||
status: 'running',
|
||||
startedAt: '14:02:31',
|
||||
output: 'transmitting…',
|
||||
},
|
||||
{
|
||||
id: 's_04',
|
||||
orderIdx: 4,
|
||||
ttpName: 'LSASS Memory Dump (BOF)',
|
||||
technique: 'T1003.001',
|
||||
host: 'WIN-EXEC-04',
|
||||
payloadType: 'bof',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 's_05',
|
||||
orderIdx: 5,
|
||||
ttpName: 'Registry Run Key Persistence',
|
||||
technique: 'T1547.001',
|
||||
host: 'WIN-DEV-12',
|
||||
payloadType: 'cmd',
|
||||
status: 'pending',
|
||||
},
|
||||
];
|
||||
60
frontend/src/mocks/session.ts
Normal file
60
frontend/src/mocks/session.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { SessionUser } from '@/types/roles';
|
||||
|
||||
/**
|
||||
* Sprint 0 mock — no backend yet. The session is selected from /login
|
||||
* and persisted in sessionStorage so route navigations preserve role.
|
||||
* Real auth lands later (D-003: local user/password v1, Keycloak OIDC v2).
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'mimic.mock.session';
|
||||
|
||||
export const MOCK_SESSIONS: Record<string, SessionUser> = {
|
||||
rt_operator: {
|
||||
id: 'usr_001',
|
||||
displayName: 'M. Dubreuil',
|
||||
role: 'rt_operator',
|
||||
engagementId: 'eng_42',
|
||||
engagementName: 'Démo Client X',
|
||||
},
|
||||
rt_lead: {
|
||||
id: 'usr_002',
|
||||
displayName: 'A. Verlhac',
|
||||
role: 'rt_lead',
|
||||
engagementId: 'eng_42',
|
||||
engagementName: 'Démo Client X',
|
||||
},
|
||||
soc_analyst: {
|
||||
id: 'usr_soc_07',
|
||||
displayName: 'SOC · session #07',
|
||||
role: 'soc_analyst',
|
||||
engagementId: 'eng_42',
|
||||
engagementName: 'Démo Client X',
|
||||
},
|
||||
};
|
||||
|
||||
export function readMockSession(): SessionUser | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'role' in parsed &&
|
||||
typeof (parsed as SessionUser).role === 'string'
|
||||
) {
|
||||
return parsed as SessionUser;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMockSession(user: SessionUser): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearMockSession(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
69
frontend/src/screens/audit/AuditPage.tsx
Normal file
69
frontend/src/screens/audit/AuditPage.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Panel } from '@/components/ui/Panel';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
|
||||
interface AuditEntry {
|
||||
ts: string;
|
||||
actor: string;
|
||||
role: 'rt_operator' | 'rt_lead' | 'soc_analyst' | 'system';
|
||||
action: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
const MOCK_AUDIT: AuditEntry[] = [
|
||||
{ ts: '14:48:31', actor: 'a.verlhac', role: 'rt_lead', action: 'report.generate', resource: 'report:rep_042_v1' },
|
||||
{ ts: '14:32:09', actor: 'rt.mickey', role: 'rt_operator', action: 'cleanup.trigger', resource: 'run_step:s_02' },
|
||||
{ ts: '14:31:55', actor: 'soc.alice', role: 'soc_analyst', action: 'detection.add', resource: 'run_step:s_02' },
|
||||
{ ts: '14:02:31', actor: 'a.verlhac', role: 'rt_lead', action: 'run.start', resource: 'run:RUN-2026-05-21-A03' },
|
||||
{ ts: '14:02:30', actor: 'system', role: 'system', action: 'run.snapshot', resource: 'run:RUN-2026-05-21-A03' },
|
||||
{ ts: '13:55:14', actor: 'a.verlhac', role: 'rt_lead', action: 'soc_session.issue', resource: 'soc_session:msoc_071' },
|
||||
{ ts: '13:40:02', actor: 'rt.mickey', role: 'rt_operator', action: 'ttp.create', resource: 'ttp:t_006' },
|
||||
];
|
||||
|
||||
const ROLE_TONE = {
|
||||
rt_operator: 'rt',
|
||||
rt_lead: 'rt',
|
||||
soc_analyst: 'soc',
|
||||
system: 'neutral',
|
||||
} as const;
|
||||
|
||||
export function AuditPage() {
|
||||
return (
|
||||
<div className="px-8 py-6 space-y-6 max-w-[1200px] mx-auto">
|
||||
<header>
|
||||
<div className="label-system mb-1">// Audit log · F13 · lead RT only</div>
|
||||
<h1 className="font-display text-fg-default" style={{ fontSize: '22px' }}>
|
||||
Append-only journal
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1" style={{ fontSize: '12.5px' }}>
|
||||
Postgres write-only role, weekly rotation as JSON Lines compressed. Active retention 30 days.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Panel title="Recent activity" meta={<span className="tabular">last 50 entries · oldest at the bottom</span>}>
|
||||
<ol>
|
||||
{MOCK_AUDIT.map((entry, idx) => (
|
||||
<li
|
||||
key={`${entry.ts}-${idx}`}
|
||||
className="grid grid-cols-[90px_180px_120px_1fr_220px] gap-4 px-4 py-2.5 items-center"
|
||||
style={{ borderBottom: '1px solid var(--line-faint)' }}
|
||||
>
|
||||
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
|
||||
{entry.ts}
|
||||
</span>
|
||||
<span className="font-mono tabular text-fg-default" style={{ fontSize: '11.5px' }}>
|
||||
{entry.actor}
|
||||
</span>
|
||||
<Pill tone={ROLE_TONE[entry.role]}>{entry.role}</Pill>
|
||||
<span className="font-mono tabular text-fg-default" style={{ fontSize: '11.5px' }}>
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className="font-mono tabular text-fg-muted text-right" style={{ fontSize: '11px' }}>
|
||||
{entry.resource}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
389
frontend/src/screens/cockpit/LiveCockpitPage.tsx
Normal file
389
frontend/src/screens/cockpit/LiveCockpitPage.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Panel } from '@/components/ui/Panel';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MOCK_RUN_STEPS } from '@/mocks/fixtures';
|
||||
import type { MockRunStep } from '@/mocks/fixtures';
|
||||
import { useSession } from '@/session/useSession';
|
||||
import { isLead, isSOC } from '@/types/roles';
|
||||
|
||||
/**
|
||||
* Live cockpit — the cornerstone screen for F5/F6/F7/F8/F15.
|
||||
* Three columns:
|
||||
* [ Steps timeline ] [ Step detail (output, evidence, detection) ] [ Side rail ]
|
||||
*
|
||||
* The control bar at the top is reserved to lead RT (F6); SOC analysts see
|
||||
* the cotation surface in the right column. Operators see evidence capture.
|
||||
*/
|
||||
|
||||
const STATUS_TONE: Record<MockRunStep['status'], 'success' | 'running' | 'pending' | 'failed' | 'aborted'> = {
|
||||
success: 'success',
|
||||
running: 'running',
|
||||
pending: 'pending',
|
||||
failed: 'failed',
|
||||
aborted: 'aborted',
|
||||
skipped: 'aborted',
|
||||
};
|
||||
|
||||
export function LiveCockpitPage() {
|
||||
const { user } = useSession();
|
||||
const steps = MOCK_RUN_STEPS;
|
||||
const [selectedId, setSelectedId] = useState<string>(
|
||||
steps.find((s) => s.status === 'running')?.id ?? steps[0]?.id ?? '',
|
||||
);
|
||||
|
||||
const selected = useMemo(
|
||||
() => steps.find((s) => s.id === selectedId) ?? steps[0],
|
||||
[steps, selectedId],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = steps.length;
|
||||
const done = steps.filter((s) => s.status === 'success' || s.status === 'failed').length;
|
||||
const detected = steps.filter((s) => s.detection?.level === 'detected').length;
|
||||
const missed = steps.filter((s) => s.detection?.level === 'missed').length;
|
||||
const partial = steps.filter((s) => s.detection?.level === 'partial').length;
|
||||
return { total, done, detected, missed, partial };
|
||||
}, [steps]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Run header + control bar */}
|
||||
<header
|
||||
className="px-6 py-4 border-b flex items-center justify-between gap-6"
|
||||
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="label-system">// Live run · pollign 500 ms</div>
|
||||
<h1 className="font-display text-fg-default" style={{ fontSize: '17px', letterSpacing: '0.05em' }}>
|
||||
RUN-2026-05-21-A03 · Démo Client X
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 text-fg-muted font-mono tabular" style={{ fontSize: '11px' }}>
|
||||
<span>scenario · purple_kickoff_v1</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>c2 · mythic</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>started 14:02:11 UTC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<Stat label="Steps" value={`${stats.done}/${stats.total}`} />
|
||||
<Stat label="Detected" value={`${stats.detected}`} tone="detected" />
|
||||
<Stat label="Partial" value={`${stats.partial}`} tone="partial" />
|
||||
<Stat label="Missed" value={`${stats.missed}`} tone="missed" />
|
||||
</div>
|
||||
|
||||
{isLead(user?.role ?? 'rt_operator') && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost">
|
||||
▮▮ Pause
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
↪ Skip
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
↻ Retry
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive">
|
||||
■ Abort
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex-1 grid min-h-0" style={{ gridTemplateColumns: '320px 1fr 360px' }}>
|
||||
{/* Column 1 — steps timeline */}
|
||||
<div
|
||||
className="overflow-auto border-r"
|
||||
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
||||
>
|
||||
<div className="px-3 py-2 label-system sticky top-0" style={{ background: 'var(--surface-1)', borderBottom: '1px solid var(--line-default)' }}>
|
||||
Steps · timeline
|
||||
</div>
|
||||
<ul>
|
||||
{steps.map((step) => {
|
||||
const isSelected = step.id === selected?.id;
|
||||
return (
|
||||
<li key={step.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(step.id)}
|
||||
className="w-full text-left px-3 py-3 transition-colors"
|
||||
style={{
|
||||
backgroundColor: isSelected ? 'var(--surface-3)' : 'transparent',
|
||||
borderLeft: `2px solid ${
|
||||
isSelected ? 'var(--accent-rt)' : 'transparent'
|
||||
}`,
|
||||
borderBottom: '1px solid var(--line-faint)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono tabular text-fg-faint" style={{ fontSize: '10.5px' }}>
|
||||
#{String(step.orderIdx).padStart(2, '0')} · {step.startedAt ?? '—'}
|
||||
</span>
|
||||
<Pill tone={STATUS_TONE[step.status]}>
|
||||
<span className={`status-dot ${step.status === 'running' ? 'pulsing' : ''}`} />
|
||||
{step.status}
|
||||
</Pill>
|
||||
</div>
|
||||
<div className="text-fg-default mt-1.5" style={{ fontSize: 12.5 }}>
|
||||
{step.ttpName}
|
||||
</div>
|
||||
<div className="font-mono tabular text-fg-muted mt-1 flex items-center gap-2" style={{ fontSize: '10.5px' }}>
|
||||
<span>{step.technique}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{step.host}</span>
|
||||
{step.detection && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span style={{ color: detectionColor(step.detection.level) }}>
|
||||
{step.detection.level.toUpperCase()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Column 2 — selected step detail */}
|
||||
<div className="overflow-auto px-6 py-5 space-y-4">
|
||||
{selected ? <StepDetail step={selected} /> : <Empty />}
|
||||
</div>
|
||||
|
||||
{/* Column 3 — side rail (cotation / evidence by role) */}
|
||||
<div
|
||||
className="overflow-auto border-l p-4 space-y-4"
|
||||
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
||||
>
|
||||
{selected && (
|
||||
<>
|
||||
{isSOC(user?.role ?? 'soc_analyst') ? (
|
||||
<DetectionPanel step={selected} />
|
||||
) : (
|
||||
<>
|
||||
<EvidencePanel step={selected} />
|
||||
<DetectionPanel step={selected} readonly />
|
||||
<CleanupPanel step={selected} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'detected' | 'partial' | 'missed';
|
||||
}) {
|
||||
const color =
|
||||
tone === 'detected'
|
||||
? 'var(--status-detected)'
|
||||
: tone === 'partial'
|
||||
? 'var(--status-partial)'
|
||||
: tone === 'missed'
|
||||
? 'var(--status-missed)'
|
||||
: 'var(--fg-default)';
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="label-system">{label}</div>
|
||||
<div className="font-mono tabular" style={{ fontSize: '20px', color, lineHeight: 1 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function detectionColor(level: 'detected' | 'partial' | 'missed'): string {
|
||||
if (level === 'detected') return 'var(--status-detected)';
|
||||
if (level === 'partial') return 'var(--status-partial)';
|
||||
return 'var(--status-missed)';
|
||||
}
|
||||
|
||||
function StepDetail({ step }: { step: MockRunStep }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="label-system mb-1">
|
||||
// Step #{String(step.orderIdx).padStart(2, '0')} · {step.technique}
|
||||
</div>
|
||||
<h2 className="font-display text-fg-default" style={{ fontSize: '20px' }}>
|
||||
{step.ttpName}
|
||||
</h2>
|
||||
</div>
|
||||
<Pill tone={STATUS_TONE[step.status]}>
|
||||
<span className={`status-dot ${step.status === 'running' ? 'pulsing' : ''}`} />
|
||||
{step.status.toUpperCase()}
|
||||
</Pill>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 font-mono tabular" style={{ fontSize: '11px' }}>
|
||||
<Field label="Host" value={step.host} />
|
||||
<Field label="Payload" value={step.payloadType} />
|
||||
<Field label="Started" value={step.startedAt ?? '—'} />
|
||||
<Field label="Ended" value={step.endedAt ?? '—'} />
|
||||
<Field
|
||||
label="Exit code"
|
||||
value={step.exitCode !== undefined ? String(step.exitCode) : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Panel title="Command output" variant="inset" cornered>
|
||||
<pre
|
||||
className="font-mono whitespace-pre-wrap p-4 text-fg-default"
|
||||
style={{ fontSize: '12px', lineHeight: 1.55, minHeight: 120 }}
|
||||
>
|
||||
{step.output ?? '— no output —'}
|
||||
</pre>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Resolved command (jinja2 → c2)" variant="inset">
|
||||
<pre
|
||||
className="font-mono whitespace-pre-wrap p-4 text-fg-muted"
|
||||
style={{ fontSize: '12px', lineHeight: 1.55 }}
|
||||
>
|
||||
{`# MIMIC-RUN:RUN-2026-05-21-A03\nMIMIC_RUN_ID=RUN-2026-05-21-A03 ${step.payloadType} -- ${step.ttpName.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
</pre>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div
|
||||
className="p-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-1)',
|
||||
border: '1px solid var(--line-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<div className="label-system">{label}</div>
|
||||
<div className="text-fg-default mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-fg-faint label-system">
|
||||
Select a step to inspect.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionPanel({ step, readonly }: { step: MockRunStep; readonly?: boolean }) {
|
||||
const levels: Array<'detected' | 'partial' | 'missed'> = ['detected', 'partial', 'missed'];
|
||||
const sources = ['EDR', 'NDR', 'SIEM', 'manual'];
|
||||
return (
|
||||
<Panel title="SOC cotation (F7)">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{levels.map((l) => {
|
||||
const active = step.detection?.level === l;
|
||||
return (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
disabled={readonly}
|
||||
className="label-system py-2 disabled:opacity-100 disabled:cursor-default"
|
||||
style={{
|
||||
background: active ? `oklch(from ${detectionColor(l)} l c h / 0.16)` : 'transparent',
|
||||
color: active ? detectionColor(l) : 'var(--fg-muted)',
|
||||
border: `1px solid ${active ? detectionColor(l) : 'var(--line-strong)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sources.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="label-system py-1.5 text-center"
|
||||
style={{
|
||||
color:
|
||||
step.detection?.source === s.toLowerCase() ? 'var(--accent-soc)' : 'var(--fg-faint)',
|
||||
border: '1px solid var(--line-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{step.detection && (
|
||||
<div className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
|
||||
latency: <span className="text-fg-default">{step.detection.latencyMs.toLocaleString()} ms</span>
|
||||
<br />
|
||||
by: <span className="text-fg-default">{step.detection.by}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidencePanel({ step }: { step: MockRunStep }) {
|
||||
return (
|
||||
<Panel title="Offensive evidence (F8)">
|
||||
<div className="p-4 space-y-2">
|
||||
{step.evidence ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone={step.evidence.status === 'success' ? 'success' : step.evidence.status === 'partial' ? 'partial' : 'failed'}>
|
||||
{step.evidence.status}
|
||||
</Pill>
|
||||
<span className="font-mono tabular text-fg-faint" style={{ fontSize: '10.5px' }}>
|
||||
· by {step.evidence.by}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-fg-muted" style={{ fontSize: '12.5px', lineHeight: 1.55 }}>
|
||||
{step.evidence.note}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-fg-faint label-system">No evidence captured yet.</p>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" className="mt-2">
|
||||
+ Add artifact
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function CleanupPanel({ step }: { step: MockRunStep }) {
|
||||
return (
|
||||
<Panel title="Cleanup (F15)">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Pill tone={step.cleanup?.status === 'success' ? 'success' : step.cleanup?.status === 'failed' ? 'failed' : 'pending'}>
|
||||
{step.cleanup?.status ?? 'pending'}
|
||||
</Pill>
|
||||
<span className="label-system text-fg-faint">jinja2 · resolved</span>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="w-full">
|
||||
Trigger cleanup
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
180
frontend/src/screens/composer/ScenarioComposerPage.tsx
Normal file
180
frontend/src/screens/composer/ScenarioComposerPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Panel } from '@/components/ui/Panel';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MOCK_TTPS, MOCK_HOSTS, MOCK_RUN_STEPS } from '@/mocks/fixtures';
|
||||
|
||||
/**
|
||||
* Scenario composer (F3) — three-column layout:
|
||||
* [ TTP library filter ] [ Step sequence (drag/drop)] [ Inspector / params ]
|
||||
*
|
||||
* Wireframe-only: drag-drop is mocked; the inspector reflects the first step.
|
||||
*/
|
||||
export function ScenarioComposerPage() {
|
||||
const draftSteps = MOCK_RUN_STEPS.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<header
|
||||
className="px-6 py-4 border-b flex items-center justify-between"
|
||||
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
||||
>
|
||||
<div>
|
||||
<div className="label-system mb-1">// Scenario composer · draft</div>
|
||||
<h1 className="font-display text-fg-default" style={{ fontSize: '17px', letterSpacing: '0.05em' }}>
|
||||
PURPLE_KICKOFF_V1
|
||||
</h1>
|
||||
<div className="font-mono tabular text-fg-muted mt-1" style={{ fontSize: '11px' }}>
|
||||
engagement · eng_42 · c2_type · mythic (locked)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost">Save draft</Button>
|
||||
<Button variant="primary">Validate & start →</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 grid min-h-0" style={{ gridTemplateColumns: '340px 1fr 320px' }}>
|
||||
{/* TTP picker */}
|
||||
<div className="overflow-auto border-r" style={{ borderColor: 'var(--line-default)' }}>
|
||||
<div className="p-3 sticky top-0 border-b" style={{ background: 'var(--surface-1)', borderColor: 'var(--line-default)' }}>
|
||||
<div className="label-system mb-2">TTP library · 8 entries</div>
|
||||
<input
|
||||
placeholder="Filter — T1059, lsass, persistence…"
|
||||
className="font-mono"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 28,
|
||||
padding: '0 10px',
|
||||
backgroundColor: 'var(--surface-inset)',
|
||||
color: 'var(--fg-default)',
|
||||
border: '1px solid var(--line-strong)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul className="p-2 space-y-px">
|
||||
{MOCK_TTPS.map((ttp) => (
|
||||
<li
|
||||
key={ttp.id}
|
||||
className="p-2"
|
||||
style={{
|
||||
border: '1px solid var(--line-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: 'var(--surface-2)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
|
||||
{ttp.technique}
|
||||
</span>
|
||||
{ttp.stealth && <Pill tone="rt">stealth</Pill>}
|
||||
</div>
|
||||
<div className="text-fg-default mt-1" style={{ fontSize: 12.5 }}>
|
||||
{ttp.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 label-system text-fg-faint">
|
||||
<span style={{ color: 'var(--accent-soc)' }}>{ttp.payloadType}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{ttp.source}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Step sequence */}
|
||||
<div className="overflow-auto p-6 space-y-3">
|
||||
<Panel title="Step sequence · drag to reorder" meta={<span className="tabular">{draftSteps.length} steps</span>}>
|
||||
<ol>
|
||||
{draftSteps.map((step) => (
|
||||
<li
|
||||
key={step.id}
|
||||
className="grid grid-cols-[40px_1fr_120px_120px_40px] items-center gap-3 px-4 py-3"
|
||||
style={{ borderBottom: '1px solid var(--line-faint)' }}
|
||||
>
|
||||
<span className="font-mono tabular text-fg-faint" style={{ fontSize: '11px' }}>
|
||||
{String(step.orderIdx).padStart(2, '0')}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-fg-default" style={{ fontSize: 13 }}>
|
||||
{step.ttpName}
|
||||
</div>
|
||||
<div className="label-system mt-0.5">
|
||||
{step.technique} · {step.payloadType}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
|
||||
{step.host}
|
||||
</div>
|
||||
<div className="font-mono tabular text-fg-faint" style={{ fontSize: '11px' }}>
|
||||
+5000 ms
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="label-system text-fg-faint hover:text-fg-default"
|
||||
aria-label="Remove step"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Panel>
|
||||
|
||||
<div className="p-4 corner-mark"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-1)',
|
||||
border: '1px dashed var(--line-strong)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
>
|
||||
<div className="label-system">+ Drop TTP here to append a step</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inspector */}
|
||||
<div
|
||||
className="overflow-auto border-l p-4 space-y-3"
|
||||
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
||||
>
|
||||
<div className="label-system">Inspector · step #03</div>
|
||||
<Panel title="Parameters (params_schema_json)" variant="inset">
|
||||
<div className="p-3 space-y-2 font-mono tabular" style={{ fontSize: '11.5px' }}>
|
||||
<KV k="encoded_cmd" v="ZGlyIEM6XA==" />
|
||||
<KV k="timeout_ms" v="30000" />
|
||||
<KV k="capture_stdout" v="true" />
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Target host">
|
||||
<div className="p-3 space-y-1.5 font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
|
||||
{MOCK_HOSTS.map((h) => (
|
||||
<div key={h.id} className="flex items-center justify-between">
|
||||
<span>{h.hostname}</span>
|
||||
<Pill tone={h.status === 'live' ? 'success' : 'pending'}>{h.status}</Pill>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Cleanup template">
|
||||
<pre
|
||||
className="font-mono p-3 whitespace-pre-wrap text-fg-muted"
|
||||
style={{ fontSize: '11px', lineHeight: 1.5 }}
|
||||
>{`{# resolved server-side before send #}
|
||||
del /F /Q %TEMP%\\{{ outputs.text }}.dat
|
||||
exit 0`}</pre>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KV({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-fg-subtle">{k}</span>
|
||||
<span className="text-fg-default">{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
frontend/src/screens/engagements/EngagementsPage.tsx
Normal file
124
frontend/src/screens/engagements/EngagementsPage.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Panel } from '@/components/ui/Panel';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MOCK_ENGAGEMENTS } from '@/mocks/fixtures';
|
||||
import type { MockEngagement } from '@/mocks/fixtures';
|
||||
|
||||
const STATUS_TONE: Record<MockEngagement['status'], 'running' | 'soc' | 'success' | 'pending'> = {
|
||||
active: 'running',
|
||||
reporting: 'soc',
|
||||
archived: 'pending',
|
||||
planning: 'success',
|
||||
};
|
||||
|
||||
export function EngagementsPage() {
|
||||
return (
|
||||
<div className="px-8 py-6 space-y-6 max-w-[1400px] mx-auto">
|
||||
<header className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="label-system mb-1">// Engagements</div>
|
||||
<h1 className="font-display text-fg-default" style={{ fontSize: '22px', letterSpacing: '0.02em' }}>
|
||||
Mission roster
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1" style={{ fontSize: '12.5px' }}>
|
||||
Each engagement is a multi-tenant container. Pick one to access its hosts, scenarios,
|
||||
runs, and reports.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary">+ New engagement</Button>
|
||||
</header>
|
||||
|
||||
<Panel
|
||||
title="Active and recent"
|
||||
meta={
|
||||
<span className="tabular">
|
||||
{MOCK_ENGAGEMENTS.length} entries · sorted by start date
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<table className="w-full" style={{ fontSize: 12.5 }}>
|
||||
<thead>
|
||||
<tr className="text-fg-subtle">
|
||||
<Th>Codename</Th>
|
||||
<Th>Client</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>C2</Th>
|
||||
<Th align="right">Operators</Th>
|
||||
<Th align="right">SOC</Th>
|
||||
<Th>Window</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_ENGAGEMENTS.map((eng, idx) => (
|
||||
<tr
|
||||
key={eng.id}
|
||||
style={{
|
||||
borderTop: idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)',
|
||||
}}
|
||||
>
|
||||
<Td>
|
||||
<div className="font-display text-fg-default" style={{ letterSpacing: '0.06em' }}>
|
||||
{eng.codename}
|
||||
</div>
|
||||
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
|
||||
{eng.id}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{eng.client}</Td>
|
||||
<Td>
|
||||
<Pill tone={STATUS_TONE[eng.status]}>
|
||||
<span className="status-dot" style={{ color: 'currentColor' }} />
|
||||
{eng.status}
|
||||
</Pill>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono tabular">{eng.c2Type.toUpperCase()}</span>
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<span className="font-mono tabular">{eng.operators}</span>
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<span className="font-mono tabular">{eng.socAnalysts}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono tabular text-fg-muted">
|
||||
{eng.startDate} → {eng.endDate}
|
||||
</span>
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Link to="/runs">
|
||||
<Button variant="ghost" size="sm">
|
||||
Enter →
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children, align = 'left' }: { children?: ReactNode; align?: 'left' | 'right' }) {
|
||||
return (
|
||||
<th
|
||||
className="label-system px-3 py-2"
|
||||
style={{ textAlign: align, fontWeight: 500 }}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, align = 'left' }: { children?: ReactNode; align?: 'left' | 'right' }) {
|
||||
return (
|
||||
<td className="px-3 py-3 align-middle" style={{ textAlign: align }}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
120
frontend/src/screens/library/TtpLibraryPage.tsx
Normal file
120
frontend/src/screens/library/TtpLibraryPage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Panel } from '@/components/ui/Panel';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MOCK_TTPS } from '@/mocks/fixtures';
|
||||
|
||||
export function TtpLibraryPage() {
|
||||
return (
|
||||
<div className="px-8 py-6 space-y-6 max-w-[1400px] mx-auto">
|
||||
<header className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="label-system mb-1">// TTP library · F1/F2</div>
|
||||
<h1 className="font-display text-fg-default" style={{ fontSize: '22px' }}>
|
||||
Maison · ATT&CK mapped TTPs
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1" style={{ fontSize: '12.5px' }}>
|
||||
CRUD over the internal red-team library. Stealth variants travel here between missions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost">Import journal C2</Button>
|
||||
<Button variant="ghost">Import ATR</Button>
|
||||
<Button variant="primary">+ New TTP</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Stat label="Total TTPs" value={String(MOCK_TTPS.length)} />
|
||||
<Stat label="Stealth variants" value={String(MOCK_TTPS.filter((t) => t.stealth).length)} />
|
||||
<Stat label="Sources · custom / atr / mission" value={`${MOCK_TTPS.filter((t) => t.source === 'custom').length} · ${MOCK_TTPS.filter((t) => t.source === 'atr').length} · ${MOCK_TTPS.filter((t) => t.source === 'mission').length}`} />
|
||||
</div>
|
||||
|
||||
<Panel title="Catalog" meta={<span className="tabular">filter · technique, tag, payload, source</span>}>
|
||||
<table className="w-full" style={{ fontSize: 12.5 }}>
|
||||
<thead>
|
||||
<tr className="text-fg-subtle">
|
||||
<Th>Technique</Th>
|
||||
<Th>Name</Th>
|
||||
<Th>Payload</Th>
|
||||
<Th>Source</Th>
|
||||
<Th>Tags</Th>
|
||||
<Th>Stealth</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_TTPS.map((ttp, idx) => (
|
||||
<tr
|
||||
key={ttp.id}
|
||||
style={{ borderTop: idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)' }}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono tabular text-fg-muted">{ttp.technique}</td>
|
||||
<td className="px-3 py-2 text-fg-default">{ttp.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-mono tabular" style={{ color: 'var(--accent-soc)' }}>
|
||||
{ttp.payloadType}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Pill tone={ttp.source === 'custom' ? 'rt' : 'neutral'}>{ttp.source}</Pill>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ttp.tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="font-mono tabular text-fg-faint"
|
||||
style={{
|
||||
fontSize: '10.5px',
|
||||
border: '1px solid var(--line-default)',
|
||||
padding: '1px 5px',
|
||||
borderRadius: 'var(--radius-xs)',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">{ttp.stealth ? <Pill tone="rt">variant</Pill> : <span className="text-fg-faint">—</span>}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button variant="subtle" size="sm">
|
||||
Edit →
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-2)',
|
||||
border: '1px solid var(--line-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-panel)',
|
||||
}}
|
||||
>
|
||||
<div className="label-system">{label}</div>
|
||||
<div className="font-mono tabular text-fg-default mt-1" style={{ fontSize: '20px' }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children?: ReactNode }) {
|
||||
return (
|
||||
<th className="label-system px-3 py-2 text-left" style={{ fontWeight: 500 }}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
300
frontend/src/screens/login/LoginPage.tsx
Normal file
300
frontend/src/screens/login/LoginPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Logo } from '@/components/brand/Logo';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
import { useSession } from '@/session/useSession';
|
||||
import { MOCK_SESSIONS } from '@/mocks/session';
|
||||
import type { Role } from '@/types/roles';
|
||||
|
||||
type Mode = 'rt' | 'soc';
|
||||
|
||||
/**
|
||||
* Login screen — two distinct paths.
|
||||
*
|
||||
* RT operators authenticate via username/password (D-003 v1, OIDC v2).
|
||||
* SOC analysts use a one-shot token (D-006: bcrypt-hashed soc_session,
|
||||
* clear value delivered out-of-band by the lead RT).
|
||||
*
|
||||
* Sprint 0 mock: no validation. Picking a role assumes that role's mock
|
||||
* session and lands the user inside the shell.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const [mode, setMode] = useState<Mode>('rt');
|
||||
const [pickedRtRole, setPickedRtRole] = useState<'rt_operator' | 'rt_lead'>('rt_lead');
|
||||
const { signIn } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const role: Role = mode === 'soc' ? 'soc_analyst' : pickedRtRole;
|
||||
const user = MOCK_SESSIONS[role];
|
||||
if (!user) return;
|
||||
signIn(user);
|
||||
void navigate(mode === 'soc' ? '/runs' : '/engagements', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--surface-0)' }}>
|
||||
{/* Left rail — masthead, identity, telemetry of the platform itself */}
|
||||
<aside
|
||||
className="hidden lg:flex flex-col justify-between w-[420px] border-r p-10"
|
||||
style={{
|
||||
borderColor: 'var(--line-default)',
|
||||
backgroundColor: 'var(--surface-1)',
|
||||
}}
|
||||
>
|
||||
<div className="space-y-10">
|
||||
<Logo build="0.1.0" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="label-system">// Mission brief</div>
|
||||
<p className="text-fg-default" style={{ fontSize: '15px', lineHeight: 1.55 }}>
|
||||
Internal Breach & Attack Simulation platform.
|
||||
<br />
|
||||
<span className="text-fg-muted">
|
||||
Replays red-team TTPs against client infrastructure in white-glove coordination
|
||||
with the SOC. Outputs a measurable MITRE ATT&CK coverage report per
|
||||
engagement.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 font-mono tabular text-fg-faint" style={{ fontSize: '11px' }}>
|
||||
<div className="flex gap-3">
|
||||
<span className="w-24 text-fg-subtle">spec</span>
|
||||
<span>ready-with-prereqs · frozen 2026-05-19</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="w-24 text-fg-subtle">blockers</span>
|
||||
<span>PR1 · PR2 · PR3 (graphic charter)</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="w-24 text-fg-subtle">deploy</span>
|
||||
<span>RT infra · Caddy + TLS · OPSEC handled by RP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 font-mono text-fg-faint" style={{ fontSize: '10px' }}>
|
||||
<div>// Mimic is for authorized engagements only.</div>
|
||||
<div>// Access is logged, audited, and reviewed weekly.</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right column — the auth form, instrument-panel inset card */}
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-16">
|
||||
<div className="w-full max-w-[420px]">
|
||||
{/* Mode switch — segmented, role-tinted */}
|
||||
<div className="flex items-center gap-1 mb-6" role="tablist" aria-label="Login mode">
|
||||
<ModeTab
|
||||
active={mode === 'rt'}
|
||||
onClick={() => setMode('rt')}
|
||||
tone="rt"
|
||||
label="RT — operator"
|
||||
hint="username + password"
|
||||
/>
|
||||
<ModeTab
|
||||
active={mode === 'soc'}
|
||||
onClick={() => setMode('soc')}
|
||||
tone="soc"
|
||||
label="SOC — analyst"
|
||||
hint="session token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="p-6 corner-mark"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-2)',
|
||||
border: '1px solid var(--line-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-panel)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="font-display text-fg-default" style={{ fontSize: '15px', letterSpacing: '0.05em' }}>
|
||||
{mode === 'rt' ? 'Operator sign-in' : 'SOC session'}
|
||||
</h1>
|
||||
<Pill tone={mode}>{mode === 'rt' ? 'RT' : 'SOC'}</Pill>
|
||||
</div>
|
||||
|
||||
{mode === 'rt' ? (
|
||||
<RtForm picked={pickedRtRole} onPick={setPickedRtRole} />
|
||||
) : (
|
||||
<SocForm />
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-3">
|
||||
<p className="label-system text-fg-faint">
|
||||
{mode === 'rt' ? 'OIDC Keycloak — v2' : 'Token delivered out-of-band'}
|
||||
</p>
|
||||
<Button type="submit" variant="primary">
|
||||
{mode === 'rt' ? 'Enter Mimic →' : 'Open session →'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p
|
||||
className="mt-6 font-mono text-fg-faint text-center"
|
||||
style={{ fontSize: '10.5px', letterSpacing: '0.08em' }}
|
||||
>
|
||||
mimic.rt.local · session ssrf-protected · audit log live
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModeTabProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
tone: 'rt' | 'soc';
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className="flex-1 text-left px-3 py-2 transition-colors"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--surface-2)' : 'transparent',
|
||||
border: '1px solid',
|
||||
borderColor: active
|
||||
? tone === 'rt'
|
||||
? 'var(--accent-rt-muted)'
|
||||
: 'var(--accent-soc-muted)'
|
||||
: 'var(--line-default)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
boxShadow: active ? 'var(--shadow-panel)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="label-system"
|
||||
style={{ color: active ? (tone === 'rt' ? 'var(--accent-rt)' : 'var(--accent-soc)') : 'var(--fg-subtle)' }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div className="font-mono text-fg-faint mt-1" style={{ fontSize: '10px' }}>
|
||||
{hint}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RtForm({
|
||||
picked,
|
||||
onPick,
|
||||
}: {
|
||||
picked: 'rt_operator' | 'rt_lead';
|
||||
onPick: (r: 'rt_operator' | 'rt_lead') => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field label="Username" placeholder="m.dubreuil" autoComplete="username" />
|
||||
<Field label="Password" type="password" placeholder="••••••••••" autoComplete="current-password" />
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="label-system">Mock role (sprint 0 only)</legend>
|
||||
<div className="flex gap-2">
|
||||
<RolePick active={picked === 'rt_operator'} onClick={() => onPick('rt_operator')}>
|
||||
RT Operator
|
||||
</RolePick>
|
||||
<RolePick active={picked === 'rt_lead'} onClick={() => onPick('rt_lead')}>
|
||||
RT Lead
|
||||
</RolePick>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SocForm() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label="Session token"
|
||||
placeholder="msoc_xxxxx-xxxx-xxxx-xxxx"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
mono
|
||||
/>
|
||||
<p className="font-mono text-fg-faint" style={{ fontSize: '10.5px', lineHeight: 1.5 }}>
|
||||
Your token was delivered out-of-band by the lead RT.
|
||||
<br />
|
||||
Scope: <span className="text-fg-muted">single engagement, read-only on telemetry, write on detection coting.</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
type?: string;
|
||||
autoComplete?: string;
|
||||
spellCheck?: boolean;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mono }: FieldProps) {
|
||||
const id = label.toLowerCase().replace(/\s+/g, '-');
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id} className="block label-system mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
spellCheck={spellCheck}
|
||||
className={mono ? 'font-mono' : 'font-sans'}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 32,
|
||||
padding: '0 10px',
|
||||
backgroundColor: 'var(--surface-inset)',
|
||||
color: 'var(--fg-default)',
|
||||
border: '1px solid var(--line-strong)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolePick({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex-1 label-system px-3 py-2 transition-colors"
|
||||
style={{
|
||||
background: active ? 'oklch(74.0% 0.165 68 / 0.10)' : 'transparent',
|
||||
color: active ? 'var(--accent-rt)' : 'var(--fg-muted)',
|
||||
border: `1px solid ${active ? 'var(--accent-rt-muted)' : 'var(--line-default)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
230
frontend/src/screens/report/ReportPage.tsx
Normal file
230
frontend/src/screens/report/ReportPage.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Panel } from '@/components/ui/Panel';
|
||||
import { Pill } from '@/components/ui/Pill';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MOCK_RUN_STEPS } from '@/mocks/fixtures';
|
||||
|
||||
/**
|
||||
* Report view (F9) — restricted MITRE matrix (techniques played only) + narration
|
||||
* + table + integrity hash. PDF / JSON / Markdown export buttons.
|
||||
*
|
||||
* Sprint 0 mock: matrix built from MOCK_RUN_STEPS detections.
|
||||
*/
|
||||
|
||||
const TACTIC_ORDER = [
|
||||
'Reconnaissance',
|
||||
'Resource Dev',
|
||||
'Initial Access',
|
||||
'Execution',
|
||||
'Persistence',
|
||||
'Privilege Esc',
|
||||
'Defense Evasion',
|
||||
'Credential Access',
|
||||
'Discovery',
|
||||
'Lateral Mvmt',
|
||||
'Collection',
|
||||
'C2',
|
||||
'Exfiltration',
|
||||
'Impact',
|
||||
];
|
||||
|
||||
interface MatrixCell {
|
||||
technique: string;
|
||||
name: string;
|
||||
level: 'detected' | 'partial' | 'missed';
|
||||
tactic: string;
|
||||
}
|
||||
|
||||
const MATRIX: MatrixCell[] = [
|
||||
{ technique: 'T1033', name: 'System Owner', level: 'detected', tactic: 'Discovery' },
|
||||
{ technique: 'T1135', name: 'Network Share', level: 'partial', tactic: 'Discovery' },
|
||||
{ technique: 'T1059.001', name: 'PowerShell', level: 'detected', tactic: 'Execution' },
|
||||
{ technique: 'T1003.001', name: 'LSASS Dump', level: 'missed', tactic: 'Credential Access' },
|
||||
{ technique: 'T1547.001', name: 'Run Key', level: 'partial', tactic: 'Persistence' },
|
||||
];
|
||||
|
||||
export function ReportPage() {
|
||||
return (
|
||||
<div className="px-8 py-6 space-y-6 max-w-[1400px] mx-auto">
|
||||
<header className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="label-system mb-1">// Mission report · F9</div>
|
||||
<h1 className="font-display text-fg-default" style={{ fontSize: '22px', letterSpacing: '0.02em' }}>
|
||||
OPERATION RUSTED ANCHOR — coverage report
|
||||
</h1>
|
||||
<div className="font-mono tabular text-fg-muted mt-1" style={{ fontSize: '11px' }}>
|
||||
engagement · eng_42 · generated 2026-05-21 16:48 UTC · version 1
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost">⇣ Markdown</Button>
|
||||
<Button variant="ghost">⇣ JSON</Button>
|
||||
<Button variant="primary">⇣ PDF</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Stat label="Techniques played" value={String(MATRIX.length)} />
|
||||
<Stat label="Detected" value={String(MATRIX.filter((c) => c.level === 'detected').length)} tone="detected" />
|
||||
<Stat label="Partial" value={String(MATRIX.filter((c) => c.level === 'partial').length)} tone="partial" />
|
||||
<Stat label="Missed" value={String(MATRIX.filter((c) => c.level === 'missed').length)} tone="missed" />
|
||||
</div>
|
||||
|
||||
<Panel title="MITRE ATT&CK · coverage matrix (restricted)" meta={<span className="tabular">5 techniques across 4 tactics</span>}>
|
||||
<div className="p-4">
|
||||
<div className="grid gap-1" style={{ gridTemplateColumns: `repeat(${TACTIC_ORDER.length}, minmax(0, 1fr))` }}>
|
||||
{TACTIC_ORDER.map((tactic) => (
|
||||
<div key={tactic} className="label-system text-center px-1 py-1" style={{ fontSize: '8.5px' }}>
|
||||
{tactic}
|
||||
</div>
|
||||
))}
|
||||
{TACTIC_ORDER.map((tactic) => {
|
||||
const cells = MATRIX.filter((c) => c.tactic === tactic);
|
||||
return (
|
||||
<div key={`col-${tactic}`} className="space-y-1">
|
||||
{cells.length === 0 ? (
|
||||
<div
|
||||
className="aspect-[3/2] flex items-center justify-center label-system text-fg-faint"
|
||||
style={{
|
||||
border: '1px dashed var(--line-faint)',
|
||||
borderRadius: 'var(--radius-xs)',
|
||||
}}
|
||||
>
|
||||
—
|
||||
</div>
|
||||
) : (
|
||||
cells.map((c) => (
|
||||
<div
|
||||
key={c.technique}
|
||||
className="aspect-[3/2] p-1.5 flex flex-col justify-between"
|
||||
style={{
|
||||
background:
|
||||
c.level === 'detected'
|
||||
? 'oklch(66.0% 0.115 148 / 0.20)'
|
||||
: c.level === 'partial'
|
||||
? 'oklch(70.0% 0.130 55 / 0.18)'
|
||||
: 'oklch(60.5% 0.175 28 / 0.18)',
|
||||
border: `1px solid ${
|
||||
c.level === 'detected'
|
||||
? 'var(--status-detected)'
|
||||
: c.level === 'partial'
|
||||
? 'var(--status-partial)'
|
||||
: 'var(--status-missed)'
|
||||
}`,
|
||||
borderRadius: 'var(--radius-xs)',
|
||||
}}
|
||||
>
|
||||
<div className="font-mono tabular text-fg-default" style={{ fontSize: '10px' }}>
|
||||
{c.technique}
|
||||
</div>
|
||||
<div className="text-fg-muted" style={{ fontSize: '9px' }}>
|
||||
{c.name}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-4 mt-4 label-system">
|
||||
<LegendDot color="var(--status-detected)" label="detected" />
|
||||
<LegendDot color="var(--status-partial)" label="partial" />
|
||||
<LegendDot color="var(--status-missed)" label="missed" />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Narration · step-by-step timeline">
|
||||
<ol className="px-4 py-2">
|
||||
{MOCK_RUN_STEPS.map((step) => (
|
||||
<li
|
||||
key={step.id}
|
||||
className="grid grid-cols-[60px_1fr_160px_140px] gap-4 py-3"
|
||||
style={{ borderBottom: '1px solid var(--line-faint)' }}
|
||||
>
|
||||
<span className="font-mono tabular text-fg-faint" style={{ fontSize: '11px' }}>
|
||||
#{String(step.orderIdx).padStart(2, '0')}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-fg-default" style={{ fontSize: 13 }}>
|
||||
{step.ttpName}
|
||||
</div>
|
||||
<div className="label-system mt-0.5">
|
||||
{step.technique} · {step.host} · {step.payloadType}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{step.detection ? (
|
||||
<Pill tone={step.detection.level}>{step.detection.level}</Pill>
|
||||
) : (
|
||||
<span className="text-fg-faint label-system">no cotation</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono tabular text-fg-muted text-right" style={{ fontSize: '11px' }}>
|
||||
{step.detection ? `${step.detection.latencyMs} ms` : '—'}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Panel>
|
||||
|
||||
<footer
|
||||
className="px-4 py-3 flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-2)',
|
||||
border: '1px solid var(--line-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
>
|
||||
<span className="label-system">// integrity</span>
|
||||
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '10.5px' }}>
|
||||
sha256 · 7c1f3a8e2b9d44a05f6b1c0eaa37d29c8d4f29c0e3e2a8b15a7f0c41d8e2b53d
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'detected' | 'partial' | 'missed';
|
||||
}) {
|
||||
const color =
|
||||
tone === 'detected'
|
||||
? 'var(--status-detected)'
|
||||
: tone === 'partial'
|
||||
? 'var(--status-partial)'
|
||||
: tone === 'missed'
|
||||
? 'var(--status-missed)'
|
||||
: 'var(--fg-default)';
|
||||
return (
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-2)',
|
||||
border: '1px solid var(--line-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-panel)',
|
||||
}}
|
||||
>
|
||||
<div className="label-system">{label}</div>
|
||||
<div className="font-mono tabular mt-1" style={{ fontSize: '22px', color, lineHeight: 1 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="status-dot" style={{ color }} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user