99 lines
4.2 KiB
TypeScript
99 lines
4.2 KiB
TypeScript
|
|
import { useState } from 'react'
|
||
|
|
import { Check, ShieldAlert, X } from 'lucide-react'
|
||
|
|
import type { Agent, Approval } from '../../types'
|
||
|
|
import { getAgentMeta } from '../../lib/agentMeta'
|
||
|
|
import { Button } from '../ui/Button'
|
||
|
|
import { cn } from '../../lib/utils'
|
||
|
|
|
||
|
|
const ACTION_LABELS: Record<string, string> = {
|
||
|
|
'docker.restart': 'Container restart',
|
||
|
|
'docker.update': 'Image update',
|
||
|
|
'generic.mutate': 'Infrastructure change',
|
||
|
|
}
|
||
|
|
|
||
|
|
type Props = {
|
||
|
|
approvals: Approval[]
|
||
|
|
agents: Agent[]
|
||
|
|
highlighted: boolean
|
||
|
|
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
|
||
|
|
onDismissHighlight?: () => void
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ApprovalCards({ approvals, agents, highlighted, onDecide, onDismissHighlight }: Props) {
|
||
|
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||
|
|
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
|
||
|
|
|
||
|
|
if (!approvals.length) return null
|
||
|
|
|
||
|
|
const agentOf = (id: string) => agents.find((a) => a.id === id)
|
||
|
|
|
||
|
|
const handle = async (id: string, approved: boolean) => {
|
||
|
|
setBusyId(id)
|
||
|
|
try {
|
||
|
|
await onDecide(id, approved, decider, '')
|
||
|
|
} finally {
|
||
|
|
setBusyId(null)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn('mx-2 mb-2 rounded-lg border bg-surface-overlay p-2.5', highlighted ? 'border-warning/50 ring-1 ring-warning/20' : 'border-border')}>
|
||
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||
|
|
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-warning">
|
||
|
|
<ShieldAlert className="h-3.5 w-3.5" />
|
||
|
|
Pending approvals ({approvals.length})
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<label className="flex items-center gap-1 text-[9px] text-foreground-muted">
|
||
|
|
As
|
||
|
|
<select
|
||
|
|
value={decider}
|
||
|
|
onChange={(e) => setDecider(e.target.value as typeof decider)}
|
||
|
|
className="rounded border border-border bg-surface px-1 py-0.5 text-[10px] text-foreground-muted"
|
||
|
|
>
|
||
|
|
<option value="mo-commander">Mo</option>
|
||
|
|
<option value="bart-commander">Bart</option>
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
{highlighted && onDismissHighlight && (
|
||
|
|
<button type="button" onClick={onDismissHighlight} className="text-[9px] text-foreground-muted hover:text-foreground-muted">Dismiss</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||
|
|
{approvals.map((a) => {
|
||
|
|
const ag = agentOf(a.agent_id)
|
||
|
|
const meta = ag ? getAgentMeta(ag.id) : null
|
||
|
|
const Icon = meta?.icon
|
||
|
|
return (
|
||
|
|
<div key={a.id} className="rounded-lg border border-border bg-surface p-2">
|
||
|
|
<div className="mb-1.5 flex items-start gap-1.5">
|
||
|
|
{Icon && (
|
||
|
|
<span className="flex h-6 w-6 items-center justify-center rounded bg-surface-overlay text-docker">
|
||
|
|
<Icon className="h-3.5 w-3.5" />
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<span className="text-[8px] uppercase tracking-wide text-warning">{ACTION_LABELS[a.action_type] || a.action_type}</span>
|
||
|
|
<p className="truncate text-[11px] font-medium text-foreground">{ag?.name || a.agent_id}</p>
|
||
|
|
</div>
|
||
|
|
<span className="font-mono text-[8px] text-foreground-faint">#{a.id.slice(0, 6)}</span>
|
||
|
|
</div>
|
||
|
|
<p className="mb-1 text-[10px] text-foreground-muted line-clamp-2">{a.action}</p>
|
||
|
|
{a.target && <p className="text-[9px] text-foreground-muted">Target: {a.target}</p>}
|
||
|
|
<div className="mt-2 flex gap-1">
|
||
|
|
<Button size="sm" variant="success" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, true)}>
|
||
|
|
<Check className="h-3 w-3" /> Approve
|
||
|
|
</Button>
|
||
|
|
<Button size="sm" variant="danger" className="flex-1 text-[10px]" disabled={busyId === a.id} onClick={() => handle(a.id, false)}>
|
||
|
|
<X className="h-3 w-3" /> Deny
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|