Files
CASAN/packages/casan-control-panel/frontend/src/pages/Incidents.tsx
T
2026-07-08 19:07:35 +09:00

113 lines
6.2 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, SettingsActor } from '../lib/api';
import { Card, StatTile, StatusBadge } from '../components/ui/Card';
const ROLES = ['viewer', 'operator', 'org-admin'];
const SCOPES = ['project', 'model', 'provider', 'tenant', 'global'];
export function Incidents() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['incidents'], queryFn: api.incidents });
const [actor, setActor] = useState<SettingsActor>({ actor: 'local-operator', role: 'operator', project: 'default', tenant: 'default' });
const [scope, setScope] = useState('project');
const [id, setId] = useState('default');
const [reason, setReason] = useState('incident containment drill');
const [message, setMessage] = useState<string | null>(null);
const killSwitch = useQuery({ queryKey: ['kill-switch', actor], queryFn: () => api.killSwitch(actor), retry: false });
const engage = useMutation({
mutationFn: () => api.engageKillSwitch(actor, { scope, id: scope === 'global' ? 'all' : id, reason }),
onSuccess: (res) => {
setMessage(res.output);
void queryClient.invalidateQueries({ queryKey: ['kill-switch'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'engage failed'),
});
const clear = useMutation({
mutationFn: () => api.clearKillSwitch(actor, { scope, id: scope === 'global' ? 'all' : id, reason }),
onSuccess: (res) => {
setMessage(res.output);
void queryClient.invalidateQueries({ queryKey: ['kill-switch'] });
},
onError: (err: any) => setMessage(err?.response?.data?.message || err.message || 'clear failed'),
});
if (isLoading || !data) return <div className="text-gray-500">Loading…</div>;
return (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatTile label="Incidents" value={data.total} />
<StatTile label="Kill-switch scopes" value={killSwitch.data?.count ?? data.kill_switch_scopes.length}
sub={killSwitch.data?.engaged.map((k) => `${k.scope}/${k.id}`).join(', ') || data.kill_switch_scopes.join(', ') || 'none'} />
</div>
<Card title="Kill-switch control" right={<StatusBadge value={(killSwitch.data?.count ?? 0) > 0 ? 'blocked' : 'ok'} />}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label className="space-y-1">
<span className="text-gray-500">Actor</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={actor.actor}
onChange={(e) => setActor({ ...actor, actor: e.target.value })} />
</label>
<label className="space-y-1">
<span className="text-gray-500">Role</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={actor.role}
onChange={(e) => setActor({ ...actor, role: e.target.value })}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">Scope</span>
<select className="w-full rounded border border-gray-300 px-3 py-2" value={scope} onChange={(e) => setScope(e.target.value)}>
{SCOPES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label className="space-y-1">
<span className="text-gray-500">ID</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={scope === 'global' ? 'all' : id}
disabled={scope === 'global'} onChange={(e) => setId(e.target.value)} />
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-gray-500">Reason</span>
<input className="w-full rounded border border-gray-300 px-3 py-2" value={reason} onChange={(e) => setReason(e.target.value)} />
</label>
<div className="flex gap-2 md:col-span-2">
<button className="rounded bg-red-600 px-4 py-2 text-white disabled:bg-gray-300"
disabled={engage.isPending} onClick={() => engage.mutate()}>
Engage
</button>
<button className="rounded border border-gray-300 px-4 py-2 text-gray-700 disabled:text-gray-300"
disabled={clear.isPending} onClick={() => clear.mutate()}>
Clear
</button>
</div>
{message && <div className="md:col-span-2 rounded border border-gray-200 bg-gray-50 p-3 text-gray-700">{message}</div>}
</div>
<div className="space-y-2 max-h-72 overflow-auto">
{(killSwitch.data?.engaged ?? []).map((k) => (
<div key={`${k.scope}-${k.id}`} className="rounded border border-red-200 bg-red-50 p-3">
<div className="font-medium text-red-800">{k.scope}/{k.id}</div>
<div className="text-xs text-red-700">{k.actor ?? 'system'} · {k.engaged_at ?? 'unknown'}</div>
<div className="text-xs text-red-700 mt-1">{k.reason}</div>
</div>
))}
{(killSwitch.data?.engaged.length ?? 0) === 0 && <div className="text-gray-500">No active kill-switches.</div>}
</div>
</div>
</Card>
<Card title="Incident log">
<div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="text-left text-gray-500 border-b border-gray-200"><th className="py-2">time</th><th>event</th><th>severity</th><th>scope</th><th>action</th></tr></thead>
<tbody>{data.incidents.map((r: any, i: number) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-2 text-gray-500">{r.timestamp?.replace('T',' ').replace('Z','')}</td>
<td className="text-gray-700">{r.event}</td><td><StatusBadge value={r.severity ?? '—'} /></td>
<td>{r.scope}</td><td>{r.action}</td>
</tr>))}</tbody>
</table></div>
</Card>
</>
);
}