Files
Metamorph/frontend/src/pages/MitrePage.tsx

119 lines
4.5 KiB
TypeScript
Raw Normal View History

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { MitreTagPicker } from '@/components/MitreTagPicker';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { Tag } from '@/components/ui/Tag';
import { ApiError, apiGet, apiPost } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import { mitreKeys, type MitreStatus, type MitreTag } from '@/lib/mitre';
export function MitrePage() {
const { state } = useAuth();
const qc = useQueryClient();
const [selected, setSelected] = useState<MitreTag[]>([]);
const [syncResult, setSyncResult] = useState<string | null>(null);
const [syncError, setSyncError] = useState<string | null>(null);
const status = useQuery({
queryKey: mitreKeys.status,
queryFn: () => apiGet<MitreStatus>('/mitre/status'),
});
const sync = useMutation({
mutationFn: () => apiPost<Record<string, unknown>>('/mitre/sync'),
onMutate: () => {
setSyncResult(null);
setSyncError(null);
},
onSuccess: async (res) => {
const counts = `${res.tactics_upserted} tactics, ${res.techniques_upserted} techniques, ${res.subtechniques_upserted} subtechniques`;
setSyncResult(`Sync completed in ${(res as { duration_ms: number }).duration_ms / 1000}s — ${counts}.`);
await qc.invalidateQueries({ queryKey: ['mitre'] });
},
onError: (e) => {
if (e instanceof ApiError) {
const p = e.payload as { error?: string; message?: string } | null;
setSyncError(p?.message ?? p?.error ?? `HTTP ${e.status}`);
} else {
setSyncError(e instanceof Error ? e.message : 'Sync failed');
}
},
});
return (
<>
<SectionHeader
prefix="MITRE"
highlight="ATT&CK"
accent="cyan"
description="Reference catalogue: tactics, techniques, sub-techniques. Tag picker reusable by tests and missions."
/>
<div className="grid gap-4 mb-6 [grid-template-columns:repeat(auto-fill,minmax(360px,1fr))]">
<Card accent="cyan" title="Source" sub={status.data?.version ? `version ${status.data.version}` : '—'}>
<p>
URL:{' '}
<code className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs break-all">
{status.data?.source_url ?? status.data?.default_url ?? '—'}
</code>
</p>
<p className="mt-2">
Last sync:{' '}
<code
className="accent-fill-cyan px-2 py-[2px] rounded-sm font-mono text-4xs"
data-testid="mitre-last-sync"
>
{status.data?.last_sync ?? 'never'}
</code>
</p>
</Card>
{state.user?.is_admin && (
<Card accent="orange" title="Sync" sub="re-pull the bundle from the configured source">
<p className="text-2xs text-text-dim mb-3">
The seed CLI runs the same logic; trigger from here only if the URL is reachable from the api container.
</p>
<Button
accent="orange"
onClick={() => sync.mutate()}
disabled={sync.isPending}
data-testid="mitre-sync"
>
{sync.isPending ? 'Syncing…' : 'Trigger MITRE sync'}
</Button>
{syncResult && <Alert accent="green" className="mt-3">{syncResult}</Alert>}
{syncError && <Alert accent="red" className="mt-3">{syncError}</Alert>}
</Card>
)}
</div>
<SectionHeader prefix="Tag" highlight="Picker" accent="orange" />
<MitreTagPicker value={selected} onChange={setSelected} />
{selected.length > 0 && (
<Card accent="purple" className="mt-6" title="Selected (preview payload)">
<pre className="text-2xs text-text-bright whitespace-pre-wrap" data-testid="mitre-selected-json">
{JSON.stringify(selected, null, 2)}
</pre>
</Card>
)}
{selected.length === 0 && (
<p className="mt-3 font-mono text-2xs text-text-dim">
Pick a tactic on the left, then a technique, then optionally a sub-technique. Selections accumulate.
</p>
)}
<div className="mt-4">
<Tag accent="cyan">Tactic</Tag>
<Tag accent="orange">Technique</Tag>
<Tag accent="purple">Sub-technique</Tag>
</div>
</>
);
}