Files
Metamorph/frontend/src/pages/HomePage.tsx
Knacky 8a1dd58c83 feat(m4): frontend MitreTagPicker + /mitre showcase page
- lib/mitre.ts: shared types (MitreTactic, Technique, Subtechnique, MitreTag
  kind/id/external_id/name) + TanStack query keys.
- components/MitreTagPicker.tsx: three-column controlled picker (tactic →
  technique → subtechnique), multi-select with chip-removal, autocomplete on
  each column, ARIA labels for screen readers. Returns MitreTag[] via
  value/onChange — drop-in for M5 template forms.
- pages/MitrePage.tsx: status card (version, source URL, last_sync), admin-
  gated Trigger Sync button with success/error alerts, picker showcase, JSON
  preview of the current selection.
- Layout adds MITRE nav link for any logged-in user; App.tsx adds the
  /mitre route under RequireAuth. HomePage roadmap bumped to next: M5
  templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:54:15 +02:00

186 lines
6.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { FlowNode } from '@/components/ui/FlowNode';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { apiGet } from '@/lib/api';
interface HealthResponse {
status: string;
version: string;
}
interface DbDiagResponse {
reachable: boolean;
alembic_revision?: string | null;
table_count?: number;
error?: string;
}
type HealthState =
| { kind: 'loading' }
| { kind: 'ok'; data: HealthResponse }
| { kind: 'error'; error: string };
type DbState =
| { kind: 'loading' }
| { kind: 'ok'; data: DbDiagResponse }
| { kind: 'error'; error: string };
export function HomePage() {
const [health, setHealth] = useState<HealthState>({ kind: 'loading' });
const [db, setDb] = useState<DbState>({ kind: 'loading' });
useEffect(() => {
let cancelled = false;
apiGet<HealthResponse>('/health', { anonymous: true })
.then((data) => !cancelled && setHealth({ kind: 'ok', data }))
.catch((err: unknown) =>
!cancelled &&
setHealth({ kind: 'error', error: err instanceof Error ? err.message : String(err) }),
);
apiGet<DbDiagResponse>('/diag/db', { anonymous: true })
.then((data) => !cancelled && setDb({ kind: 'ok', data }))
.catch((err: unknown) =>
!cancelled &&
setDb({ kind: 'error', error: err instanceof Error ? err.message : String(err) }),
);
return () => {
cancelled = true;
};
}, []);
return (
<>
<header className="text-center mb-12">
<h1 className="font-mono text-[28px] font-bold tracking-tight text-text-bright">
<span className="text-red">Meta</span>
<span>morph</span>{' '}
<span className="text-purple">Purple Team Platform</span>
</h1>
<p className="font-mono text-sm font-light text-text-dim mt-2">
Collaborative red &amp; blue test orchestration M4 milestone (MITRE ATT&amp;CK)
</p>
</header>
<SectionHeader
prefix="System"
highlight="Health"
accent="cyan"
description="Live status pulled from /api/v1/health and /api/v1/diag/db."
/>
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(420px,1fr))]">
<Card
accent={health.kind === 'ok' ? 'green' : health.kind === 'error' ? 'red' : 'cyan'}
title="API"
sub={
health.kind === 'loading'
? 'probing…'
: health.kind === 'error'
? 'unreachable'
: `version ${health.data.version}`
}
>
{health.kind === 'loading' && <p>Waiting for the backend</p>}
{health.kind === 'ok' && (
<p>
Status:{' '}
<code className="accent-fill-green px-2 py-[2px] rounded-sm font-mono text-4xs">
{health.data.status}
</code>
</p>
)}
{health.kind === 'error' && (
<p>
Error:{' '}
<code className="accent-fill-red px-2 py-[2px] rounded-sm font-mono text-4xs">
{health.error}
</code>
</p>
)}
</Card>
<Card
accent={db.kind === 'ok' && db.data.reachable ? 'blue' : db.kind === 'error' ? 'red' : 'cyan'}
title="Database"
sub={
db.kind === 'loading'
? 'probing…'
: db.kind === 'error'
? 'unreachable'
: db.data.reachable
? `revision ${db.data.alembic_revision?.slice(0, 8) ?? 'unknown'}`
: 'unreachable'
}
>
{db.kind === 'ok' && db.data.reachable && (
<p>
Tables:{' '}
<code
className="accent-fill-blue px-2 py-[2px] rounded-sm font-mono text-4xs"
data-testid="db-table-count"
>
{db.data.table_count}
</code>{' '}
· Alembic head reached.
</p>
)}
{(db.kind === 'loading' || (db.kind === 'ok' && !db.data.reachable)) && (
<p>Querying schema metadata</p>
)}
{db.kind === 'error' && (
<p>
Error:{' '}
<code className="accent-fill-red px-2 py-[2px] rounded-sm font-mono text-4xs">
{db.error}
</code>
</p>
)}
</Card>
<Card accent="purple" title="Roadmap" sub="14 milestones">
<p>
M0 + M1 + M2 + M3 + M4 done. Next:{' '}
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs">
M5 Test &amp; scenario templates
</code>
.
</p>
</Card>
</div>
<SectionHeader
prefix="Design"
highlight="Tokens"
accent="orange"
description="Sanity check of the RTOps design system primitives (cf. tasks/design.md)."
/>
<div className="mb-6">
<Tag accent="red">EVASION</Tag>
<Tag accent="purple">C2</Tag>
<Tag accent="cyan">LATERAL</Tag>
<Tag accent="orange">CRED</Tag>
<Tag accent="green">PHISH</Tag>
<Tag accent="teal">PERSIST</Tag>
</div>
<div className="flex items-center gap-1 flex-wrap py-3 mb-6">
<FlowNode accent="orange">recon</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="green">phish</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="purple">c2</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="cyan">lateral</FlowNode>
<span className="text-text-dim font-mono text-2xs"></span>
<FlowNode accent="red">impact</FlowNode>
</div>
<div className="flex gap-2">
<Button accent="cyan">Primary</Button>
<Button accent="red">Danger</Button>
<Button variant="ghost">Cancel</Button>
</div>
</>
);
}