a11oy / web /src /components /command /guardian-decisions-tile.tsx
betterwithage's picture
sync(space): complete build context — fix BUILD_ERROR (CTO)
518343a verified
Raw
History Blame
11.3 kB
import { useStandardQuery } from '@szl-holdings/api-client-react';
import {
AlertTriangle,
CheckCircle2,
Clock,
ExternalLink,
ShieldAlert,
ShieldCheck,
ShieldX,
} from 'lucide-react';
import { Link } from 'wouter';
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '');
type Decision = 'allow' | 'require-approval' | 'require-dual-approval' | 'block';
interface RecentRow {
id: number;
requestId: string;
agentId: string | null;
tier: string;
action: string;
toolId: string | null;
decision: Decision;
reason: string | null;
decidedAt: string;
traceId: string | null;
traceDomain: string | null;
approvalStatus: string | null;
approvalType: string | null;
approvalExpiresAt: string | null;
}
interface SummaryResponse {
data: {
windowMinutes: number;
since: string;
counts: {
allow: number;
requireApproval: number;
requireDualApproval: number;
block: number;
total: number;
};
pendingApprovals: number;
recent: RecentRow[];
};
}
function timeAgo(iso?: string | null): string {
if (!iso) return '—';
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return iso;
const s = Math.max(1, Math.floor((Date.now() - t) / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
function decisionLabel(d: Decision): string {
switch (d) {
case 'allow':
return 'allow';
case 'require-approval':
return 'approval';
case 'require-dual-approval':
return 'dual approval';
case 'block':
return 'denied';
}
}
const DECISION_STYLE: Record<Decision, { fg: string; bg: string; border: string }> = {
allow: { fg: '#22c55e', bg: 'rgba(34,197,94,0.10)', border: 'rgba(34,197,94,0.30)' },
'require-approval': {
fg: '#d4a054',
bg: 'rgba(212,160,84,0.10)',
border: 'rgba(212,160,84,0.30)',
},
'require-dual-approval': {
fg: '#f97316',
bg: 'rgba(249,115,22,0.10)',
border: 'rgba(249,115,22,0.30)',
},
block: { fg: '#ef4444', bg: 'rgba(239,68,68,0.10)', border: 'rgba(239,68,68,0.30)' },
};
function CountBlock({
label,
value,
Icon,
color,
}: {
label: string;
value: number;
Icon: typeof ShieldCheck;
color: string;
}) {
return (
<div
className="flex items-center gap-3 px-3 py-2 rounded-lg flex-1 min-w-[120px]"
style={{
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-surface-border)',
}}
>
<div
className="w-8 h-8 rounded flex items-center justify-center"
style={{ background: `${color}14`, border: `1px solid ${color}30` }}
>
<Icon className="w-4 h-4" style={{ color }} />
</div>
<div className="flex flex-col min-w-0">
<span
className="text-[18px] font-bold leading-tight"
style={{ color: 'var(--color-fg-primary)' }}
>
{value.toLocaleString()}
</span>
<span
className="text-[9px] font-mono uppercase tracking-wider truncate"
style={{ color: 'var(--color-fg-muted)' }}
>
{label}
</span>
</div>
</div>
);
}
export function GuardianDecisionsTile() {
const q = useStandardQuery<SummaryResponse>({
queryKey: ['guardian', 'decisions-summary', '1h'],
queryFn: async () => {
const res = await fetch(`${BASE}/api/guardian/decisions/summary?windowMinutes=60&limit=8`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
return res.json() as Promise<SummaryResponse>;
},
refetchInterval: 30_000,
});
const data = q.data?.data;
const counts = data?.counts ?? {
allow: 0,
requireApproval: 0,
requireDualApproval: 0,
block: 0,
total: 0,
};
const pending = data?.pendingApprovals ?? 0;
const recent = data?.recent ?? [];
return (
<div
className="rounded-xl overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-surface-border)',
padding: '20px',
}}
>
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4" style={{ color: '#d4a054' }} />
<span
className="text-xs font-bold uppercase tracking-widest"
style={{ color: 'var(--color-fg-muted)' }}
>
Guardian Decisions
</span>
<span
className="text-[9px] font-mono px-1.5 py-0.5 rounded"
style={{
color: 'var(--color-fg-muted)',
background: 'rgba(255,255,255,0.04)',
border: '1px solid var(--color-surface-border)',
}}
>
last 60m
</span>
</div>
<Link
href={`${BASE}/operations/guardian/approvals`}
className="text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded inline-flex items-center gap-1"
style={{
color: '#d4a054',
backgroundColor: 'rgba(212,160,84,0.10)',
border: '1px solid rgba(212,160,84,0.30)',
}}
>
Open Console <ExternalLink className="w-3 h-3" />
</Link>
</div>
{q.isLoading ? (
<div
className="text-[11px] font-mono py-6 text-center"
style={{ color: 'var(--color-fg-muted)' }}
>
Loading…
</div>
) : q.error ? (
<div
className="rounded p-3 text-[11px] font-mono flex items-start gap-2"
style={{
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.25)',
color: '#ef4444',
}}
>
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div>
<div className="font-semibold">Failed to load Guardian decisions</div>
<div className="opacity-80 mt-0.5">{(q.error as Error).message}</div>
</div>
</div>
) : (
<>
<div className="flex flex-wrap gap-2 mb-4">
<CountBlock label="Allowed" value={counts.allow} Icon={CheckCircle2} color="#22c55e" />
<CountBlock
label="Approval"
value={counts.requireApproval + counts.requireDualApproval}
Icon={ShieldAlert}
color="#d4a054"
/>
<CountBlock label="Blocked" value={counts.block} Icon={ShieldX} color="#ef4444" />
<CountBlock label="Awaiting you" value={pending} Icon={Clock} color="#8b7ac8" />
</div>
<div
className="text-[9px] font-mono uppercase tracking-widest mb-2"
style={{ color: 'var(--color-fg-muted)' }}
>
Recent denials & pending approvals
</div>
{recent.length === 0 ? (
<div
className="text-[11px] font-mono py-4 text-center rounded"
style={{
color: 'var(--color-fg-muted)',
border: '1px dashed var(--color-surface-border)',
}}
>
No blocked or pending-approval decisions in the last hour.
</div>
) : (
<div className="flex flex-col gap-1.5">
{recent.map((row) => {
const ds = DECISION_STYLE[row.decision];
const traceHref = row.traceId
? `${BASE}/cognitive/traces?trace=${encodeURIComponent(row.traceId)}`
: null;
return (
<div
key={row.id}
className="flex items-start gap-2 px-2.5 py-1.5 rounded"
style={{
background: 'rgba(255,255,255,0.015)',
border: '1px solid var(--color-surface-border)',
}}
>
<span
className="text-[8px] font-mono font-semibold tracking-wider px-1.5 py-px rounded uppercase shrink-0 mt-0.5"
style={{ color: ds.fg, background: ds.bg, border: `1px solid ${ds.border}` }}
>
{decisionLabel(row.decision)}
</span>
<div className="flex-1 min-w-0">
<div
className="text-[11px] font-semibold truncate"
style={{ color: 'var(--color-fg-primary)' }}
title={row.action}
>
{row.action}
</div>
<div
className="text-[10px] font-mono truncate"
style={{ color: 'var(--color-fg-muted)' }}
>
{row.agentId ?? 'anon-agent'}
{row.toolId ? ` · ${row.toolId}` : ''}
{row.traceDomain ? ` · ${row.traceDomain}` : ''}
{' · '}
<span style={{ opacity: 0.7 }}>{timeAgo(row.decidedAt)}</span>
</div>
{row.reason && (
<div
className="text-[10px] mt-0.5 truncate"
style={{ color: 'var(--color-fg-muted)' }}
title={row.reason}
>
{row.reason}
</div>
)}
</div>
{traceHref ? (
<Link
href={traceHref}
className="text-[9px] font-mono uppercase tracking-wider px-1.5 py-0.5 rounded inline-flex items-center gap-1 shrink-0"
style={{
color: '#8b7ac8',
background: 'rgba(139,122,200,0.10)',
border: '1px solid rgba(139,122,200,0.30)',
}}
title={`Trace ${row.traceId}`}
>
Trace <ExternalLink className="w-2.5 h-2.5" />
</Link>
) : (
<span
className="text-[9px] font-mono uppercase tracking-wider px-1.5 py-0.5 rounded shrink-0"
style={{
color: 'var(--color-fg-muted)',
border: '1px solid var(--color-surface-border)',
}}
title="No trace recorded for this decision"
>
no trace
</span>
)}
</div>
);
})}
</div>
)}
</>
)}
</div>
);
}
export default GuardianDecisionsTile;