Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.
Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ShieldCheck, X } from 'lucide-react'
|
||||
import { fetchApprovalHistory } from '../../lib/api'
|
||||
import type { Agent, Approval } from '../../types'
|
||||
import { getAgentMeta } from '../../lib/agentMeta'
|
||||
import { Badge } from '../ui/Badge'
|
||||
import { Button } from '../ui/Button'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
type Filter = 'pending' | 'approved' | 'denied' | 'all'
|
||||
|
||||
type Props = {
|
||||
agents: Agent[]
|
||||
livePending: Approval[]
|
||||
onDecide: (id: string, approved: boolean, decidedBy: string, note: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function ApprovalInbox({ agents, livePending, onDecide }: Props) {
|
||||
const [filter, setFilter] = useState<Filter>('pending')
|
||||
const [items, setItems] = useState<Approval[]>([])
|
||||
const [stats, setStats] = useState({ pending: 0, approved: 0, denied: 0, total: 0 })
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [decider, setDecider] = useState<'mo-commander' | 'bart-commander'>('mo-commander')
|
||||
const [note, setNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetchApprovalHistory(filter === 'all' ? 'all' : filter)
|
||||
setItems(res.approvals)
|
||||
if (res.stats) setStats(res.stats)
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => { load() }, [load, livePending])
|
||||
|
||||
const selected = useMemo(() => items.find((a) => a.id === selectedId) || items[0] || null, [items, selectedId])
|
||||
const agentOf = (id: string) => agents.find((a) => a.id === id)
|
||||
|
||||
const handleDecide = async (approved: boolean) => {
|
||||
if (!selected || selected.status !== 'pending') return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onDecide(selected.id, approved, decider, note)
|
||||
setNote('')
|
||||
await load()
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-docker" />
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">Approval Inbox</h2>
|
||||
<p className="text-[10px] text-foreground-muted">Mo & Bart review mutating agent actions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Badge variant="warning">{stats.pending} pending</Badge>
|
||||
<Badge variant="success">{stats.approved} ok</Badge>
|
||||
<Badge variant="danger">{stats.denied} denied</Badge>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex shrink-0 gap-1 border-b border-border px-2 py-1">
|
||||
{(['pending', 'approved', 'denied', 'all'] as Filter[]).map((f) => (
|
||||
<button key={f} type="button" onClick={() => setFilter(f)} className={cn('rounded px-2 py-0.5 text-[10px] capitalize', filter === f ? 'bg-docker-light text-docker' : 'text-foreground-muted')}>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(200px,0.9fr)_1.1fr]">
|
||||
<div className="scrollbar-thin overflow-y-auto border-r border-border p-1">
|
||||
{!items.length && <p className="p-4 text-center text-[10px] text-foreground-faint">No {filter} requests.</p>}
|
||||
{items.map((a) => {
|
||||
const ag = agentOf(a.agent_id)
|
||||
const meta = ag ? getAgentMeta(ag.id) : null
|
||||
const Icon = meta?.icon
|
||||
return (
|
||||
<button key={a.id} type="button" onClick={() => setSelectedId(a.id)} className={cn('mb-1 flex w-full gap-2 rounded-lg border p-2 text-left', selected?.id === a.id ? 'border-docker/40 bg-docker-light' : 'border-transparent hover:bg-surface-overlay')}>
|
||||
{Icon && <Icon className="h-4 w-4 shrink-0 text-docker" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[11px] font-medium text-foreground">{a.action.slice(0, 80)}</span>
|
||||
<span className="block text-[9px] text-foreground-faint">{ag?.name || a.agent_id}</span>
|
||||
</span>
|
||||
<Badge variant={a.status === 'pending' ? 'warning' : a.status === 'approved' ? 'success' : 'danger'}>{a.status}</Badge>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="scrollbar-thin overflow-y-auto p-3">
|
||||
<Badge variant={selected.status === 'pending' ? 'warning' : 'success'}>{selected.status}</Badge>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
|
||||
<div><dt className="text-foreground-faint">Agent</dt><dd className="text-foreground">{agentOf(selected.agent_id)?.name}</dd></div>
|
||||
<div><dt className="text-foreground-faint">Type</dt><dd className="text-foreground">{selected.action_type}</dd></div>
|
||||
<div className="col-span-2"><dt className="text-foreground-faint">Action</dt><dd className="text-foreground-muted">{selected.action}</dd></div>
|
||||
<div className="col-span-2"><dt className="text-foreground-faint">Reason</dt><dd className="text-foreground-muted">{selected.reason}</dd></div>
|
||||
</dl>
|
||||
{selected.status === 'pending' && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<select value={decider} onChange={(e) => setDecider(e.target.value as typeof decider)} className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted">
|
||||
<option value="mo-commander">Decide as Mo</option>
|
||||
<option value="bart-commander">Decide as Bart</option>
|
||||
</select>
|
||||
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="Note (optional)" className="w-full rounded border border-border bg-surface px-2 py-1 text-[10px] text-foreground-muted" />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="success" className="flex-1" disabled={busy} onClick={() => handleDecide(true)}><Check className="h-3 w-3" /> Approve</Button>
|
||||
<Button variant="danger" className="flex-1" disabled={busy} onClick={() => handleDecide(false)}><X className="h-3 w-3" /> Deny</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user