Files
heicode-mananger/heicode/web/default/src/features/agnet-console/pages.tsx
T

2596 lines
95 KiB
TypeScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, type ComponentType, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
Activity,
AlertOctagon,
ArrowUpRight,
Building2,
CheckCircle2,
CircleDashed,
Coins,
FileSearch,
Filter,
GitCommit,
PlayCircle,
Plus,
Rocket,
Search,
ShieldCheck,
Tag,
Trash2,
XCircle,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
// /audit pulls from mcp-server §5.10 stub now, not the Heicode-local
// controller — the contract doc names that endpoint as the canonical
// source. The shape of McpAuditEntry is wider than the legacy local one
// so the redacted-card renderer keeps working.
// /sk-sources resource list/create/revoke moved to mcp-server §2 P1
// ResourceBinding (commit ?).
import {
createResource,
listMcpAuditLogs,
listResources,
revokeResource,
type ResourceBinding,
type ResourceType,
} from '@/lib/heicode-mcp'
import { listManagerResources } from '@/lib/manager-resources'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { QueryState } from '@/components/query-state'
import {
approveAgnetApproval,
getAgnetDeploymentEvents,
getAgnetDeploymentTimeline,
listAgnetApprovals,
listAgnetCredentialLeases,
listAgnetDeployments,
rejectAgnetApproval,
revokeAgnetCredentialLease,
simulateAgnetDeploymentEvents,
type AgnetApprovalRequest,
type AgnetCredentialLease,
type AgnetDeployment,
type AgnetRuntimeExecution,
type AgnetSKAccessPolicy,
} from './api'
import { AzureCloudBindingSheet } from './azure-cloud-binding-sheet'
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
type SecretStoreStatus = {
configured: boolean
reachable: boolean
}
const STATUS_TO_KEY: Record<string, StatusKey> = {
running: 'running',
active: 'running',
in_progress: 'running',
succeeded: 'success',
success: 'success',
completed: 'success',
failed: 'failed',
error: 'failed',
rejected: 'failed',
pending: 'pending',
queued: 'pending',
awaiting: 'pending',
}
function classifyStatus(status: string): StatusKey {
return STATUS_TO_KEY[(status || '').toLowerCase()] ?? 'pending'
}
function StatusBadge({ phase }: { phase: string }) {
const k = classifyStatus(phase)
const map: Record<StatusKey, { cls: string; icon: ReactNode }> = {
running: {
cls: 'bg-[color-mix(in_oklch,var(--primary)_22%,transparent)] text-primary ring-primary/40',
icon: <PlayCircle className='h-3 w-3' />,
},
success: {
cls: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30',
icon: <CheckCircle2 className='h-3 w-3' />,
},
failed: {
cls: 'bg-rose-500/15 text-rose-400 ring-rose-500/30',
icon: <XCircle className='h-3 w-3' />,
},
pending: {
cls: 'bg-amber-500/15 text-amber-400 ring-amber-500/30',
icon: <CircleDashed className='h-3 w-3' />,
},
}
const m = map[k]
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-[0.12em] uppercase ring-1 ring-inset',
m.cls
)}
>
{m.icon}
{phase || 'unknown'}
</span>
)
}
function PageSurface(props: {
title: string
subtitle?: string
toolbar?: ReactNode
children: ReactNode
}) {
return (
<section className='space-y-5 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_70%,transparent)] p-5 backdrop-blur'>
<header className='flex flex-col gap-3 border-b border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pb-4 sm:flex-row sm:items-end sm:justify-between'>
<div>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
Heicode Manager
</p>
<h2 className='mt-1 text-xl font-semibold'>{props.title}</h2>
{props.subtitle && (
<p className='text-muted-foreground mt-1 text-sm'>
{props.subtitle}
</p>
)}
</div>
{props.toolbar && (
<div className='flex flex-wrap items-center gap-2'>
{props.toolbar}
</div>
)}
</header>
{props.children}
</section>
)
}
function EmptySurface(props: { title: string; hint?: string }) {
return (
<div className='rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_45%,transparent)] p-10 text-center'>
<FileSearch className='text-muted-foreground mx-auto h-6 w-6' />
<p className='mt-3 text-sm font-medium'>{props.title}</p>
{props.hint && (
<p className='text-muted-foreground mt-1 text-xs'>{props.hint}</p>
)}
</div>
)
}
function LoadingGrid({
rows = 4,
height = 'h-24',
}: {
rows?: number
height?: string
}) {
return (
<div className='grid gap-3 sm:grid-cols-2'>
{Array.from({ length: rows }).map((_, idx) => (
<Skeleton key={idx} className={cn('rounded-xl', height)} />
))}
</div>
)
}
function MetaPill({
icon: Icon,
label,
value,
}: {
icon: ComponentType<{ className?: string }>
label: string
value: string
}) {
return (
<span className='text-muted-foreground ring-border inline-flex items-center gap-1.5 rounded-full bg-[color-mix(in_oklch,var(--card)_55%,transparent)] px-2.5 py-1 text-[10px] font-semibold tracking-[0.12em] uppercase ring-1 ring-inset'>
<Icon className='text-primary h-3 w-3' />
<span>{label}</span>
<span className='text-foreground font-mono tracking-normal normal-case'>
{value}
</span>
</span>
)
}
function describeRiskLevel(dep: AgnetDeployment): {
label: string
tone: 'low' | 'mid' | 'high'
} {
const explicit = dep.orchestration_plan?.risk_level?.toLowerCase()
if (explicit === 'high') return { label: 'high', tone: 'high' }
if (explicit === 'medium' || explicit === 'mid')
return { label: 'medium', tone: 'mid' }
if (explicit === 'low') return { label: 'low', tone: 'low' }
const objective = (dep.orchestration_plan?.objective || '').toLowerCase()
if (objective.includes('production') || objective.includes('critical')) {
return { label: 'high', tone: 'high' }
}
if (objective.includes('staging') || objective.includes('pilot')) {
return { label: 'mid', tone: 'mid' }
}
return { label: 'low', tone: 'low' }
}
function describeSubMode(dep: AgnetDeployment): string {
return dep.sub_mode || dep.orchestration_plan?.sub_mode || 'agile'
}
function describeBudget(dep: AgnetDeployment): string {
const budget = dep.orchestration_plan?.budget
if (!budget) {
const agents = dep.orchestration_plan?.agents?.length ?? 0
return `${agents} agents`
}
const parts = []
if (budget.max_cost_usd > 0) parts.push(`$${budget.max_cost_usd}`)
if (budget.max_tokens > 0) parts.push(`${budget.max_tokens} tokens`)
if (budget.max_duration_sec > 0)
parts.push(`${Math.round(budget.max_duration_sec / 60)}m`)
return parts.length > 0 ? parts.join(' / ') : '—'
}
function describeScope(dep: AgnetDeployment): string {
const firstGrant = dep.orchestration_plan?.agents?.flatMap(
(agent) => agent.resource_grants || []
)[0]
return (
firstGrant?.binding_scope ||
dep.orchestration_plan?.metadata?.tenant_id ||
'—'
)
}
function collectResourceGrants(
dep: AgnetDeployment
): Record<string, unknown>[] {
const manifestGrants = dep.permission_manifest?.resource_grants
if (manifestGrants && manifestGrants.length > 0) {
return manifestGrants.map((grant) => ({
...grant,
permission_scope: grant.allowed_actions || [],
}))
}
return (
dep.orchestration_plan?.agents
?.flatMap((agent) => agent.resource_grants || [])
.map((grant) => grant as Record<string, unknown>) ?? []
)
}
function describeSecretRefs(dep: AgnetDeployment): string {
const grants = collectResourceGrants(dep)
const refs = grants.filter((grant) => {
const secretRef = grant.secret_ref
return typeof secretRef === 'string' && secretRef.trim() !== ''
})
if (refs.length > 0) return `${refs.length}/${grants.length || refs.length}`
return grants.length > 0 ? '0' : '—'
}
function formatRelativeTime(value: string | undefined): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const diff = Date.now() - date.getTime()
const sec = Math.round(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.round(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h ago`
const day = Math.round(hr / 24)
return `${day}d ago`
}
// maskSecretRef shows enough of a secret_ref to identify which vault
// entry it points at, but NEVER the full value. Product docs §10
// "高级展开" + §13.9 forbid leaking plaintext credentials anywhere in
// the UI. Accepted shapes are azkv://... Key Vault refs or opaque IDs.
// We keep the scheme and the first 6 chars of the leaf, then ellipsis.
function maskSecretRef(ref: string | undefined): string {
if (!ref) return '—'
const trimmed = ref.trim()
if (trimmed === '') return '—'
const slash = trimmed.lastIndexOf('/')
if (slash < 0 || slash >= trimmed.length - 1) {
// No path segments — mask anything beyond first 6 chars.
return trimmed.length <= 6 ? trimmed : trimmed.slice(0, 6) + '***'
}
const prefix = trimmed.slice(0, slash + 1)
const leaf = trimmed.slice(slash + 1)
const head = leaf.length <= 6 ? leaf : leaf.slice(0, 6)
return prefix + head + '***'
}
// Stable status-to-color mapping for the grant status pill in the
// manifest preview. Mirrors the active/revoked vocabulary the
// device-binding work standardised in May.
function grantStatusToneClass(status: string | undefined): string {
switch (status) {
case 'active':
return 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30'
case 'pending':
return 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
case 'disabled':
case 'revoked':
return 'bg-rose-500/15 text-rose-400 ring-rose-500/30'
default:
return 'bg-muted/40 text-muted-foreground ring-border/60'
}
}
function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const phase = dep.phase || dep.status
const risk = describeRiskLevel(dep)
const grants = collectResourceGrants(dep)
const simulateMutation = useMutation({
mutationFn: () => simulateAgnetDeploymentEvents(dep.deployment_id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['agnet', 'deployments'] })
void queryClient.invalidateQueries({
queryKey: ['agnet', 'deployment-events', dep.deployment_id],
})
toast.success(t('Simulated events recorded'))
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t('Failed to simulate events')
)
},
})
// M5 — permission manifest 折叠预览. Default folded per product docs
// §10 "高级用户可以展开 manifest 预览,但默认折叠". Visible cell
// count keeps the page calm; the toggle reveals the per-grant table.
const [manifestOpen, setManifestOpen] = useState(false)
return (
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Run detail')}
</p>
<p className='text-foreground mt-1 truncate font-mono text-sm'>
{dep.deployment_id}
</p>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{dep.orchestration_plan?.objective ||
dep.orchestration_plan?.template_hint ||
t('No objective')}
</p>
</div>
<div className='flex shrink-0 items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
className='h-8 gap-1 rounded-xl text-xs'
disabled={simulateMutation.isPending}
onClick={() => simulateMutation.mutate()}
>
<Rocket className='h-3.5 w-3.5' />
{t('Simulate')}
</Button>
<StatusBadge phase={phase} />
</div>
</div>
<div className='mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-6'>
<MetaPill icon={Activity} label={t('status')} value={phase || '—'} />
<MetaPill
icon={Rocket}
label={t('mode')}
value={describeSubMode(dep)}
/>
<MetaPill icon={Tag} label={t('risk')} value={risk.label} />
<MetaPill
icon={Coins}
label={t('budget')}
value={describeBudget(dep)}
/>
<MetaPill
icon={Building2}
label={t('scope')}
value={describeScope(dep)}
/>
<MetaPill
icon={ShieldCheck}
label='secret_ref'
value={describeSecretRefs(dep)}
/>
</div>
<div className='mt-4 grid gap-3 md:grid-cols-3'>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3 md:col-span-3'>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0'>
<p className='text-foreground flex items-center gap-2 text-xs font-semibold'>
<FileSearch className='text-primary h-3.5 w-3.5' />
{t('Permission manifest')}
</p>
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
{t('Run manifest hint')}
</p>
<p className='text-muted-foreground mt-2 font-mono text-[11px]'>
{grants.length} {t('resource grants')}
</p>
</div>
{grants.length > 0 && (
<Button
type='button'
variant='ghost'
size='sm'
className='shrink-0 rounded-xl'
onClick={() => setManifestOpen((v) => !v)}
>
{manifestOpen ? t('Hide manifest') : t('Show manifest')}
</Button>
)}
</div>
{/* Folded by default per product docs §10 — only the five
policy-safe fields per grant. No plaintext secrets — the
secret_ref column shows a masked vault pointer only. */}
{manifestOpen && grants.length > 0 && (
<div className='mt-4 overflow-x-auto'>
<table className='w-full min-w-[640px] border-collapse text-left text-[11px]'>
<thead className='text-muted-foreground'>
<tr>
<th className='py-1 pr-3 font-medium'>{t('Resource')}</th>
<th className='py-1 pr-3 font-medium'>
{t('Allowed actions')}
</th>
<th className='py-1 pr-3 font-medium'>
{t('Constraints')}
</th>
<th className='py-1 pr-3 font-medium'>{t('secret_ref')}</th>
<th className='py-1 pr-3 font-medium'>{t('Status')}</th>
</tr>
</thead>
<tbody>
{grants.map((g, idx) => {
const resType = String(g.resource_type || '—')
const resId = String(g.resource_id || '—')
const actions = Array.isArray(g.permission_scope)
? (g.permission_scope as string[])
: []
const constraints =
(g.constraints as Record<string, string>) || {}
const constraintEntries = Object.entries(constraints).slice(
0,
6
)
const secret = maskSecretRef(
g.secret_ref as string | undefined
)
const status = String(g.status || 'active')
return (
<tr
key={
(g.grant_id as string) || `${resType}:${resId}:${idx}`
}
className='border-t border-dashed border-[color-mix(in_oklch,var(--primary)_14%,var(--border))]'
>
<td className='py-2 pr-3 align-top'>
<span className='text-primary inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-1.5 py-0.5 font-mono text-[10px] uppercase'>
{resType}
</span>
<p className='text-muted-foreground mt-1 truncate font-mono text-[10px]'>
{resId}
</p>
</td>
<td className='py-2 pr-3 align-top'>
{actions.length === 0 ? (
<span className='text-muted-foreground'>—</span>
) : (
<div className='flex flex-wrap gap-1'>
{actions.map((a) => (
<span
key={a}
className='bg-muted/40 inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-[10px]'
>
{a}
</span>
))}
</div>
)}
</td>
<td className='py-2 pr-3 align-top'>
{constraintEntries.length === 0 ? (
<span className='text-muted-foreground'>—</span>
) : (
<div className='flex flex-wrap gap-1'>
{constraintEntries.map(([k, v]) => (
<span
key={k}
className='bg-muted/30 inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-[10px]'
title={`${k}=${v}`}
>
{k}=
{String(v).length > 24
? String(v).slice(0, 24) + '…'
: v}
</span>
))}
</div>
)}
</td>
<td className='text-muted-foreground py-2 pr-3 align-top font-mono text-[10px]'>
{secret}
</td>
<td className='py-2 pr-3 align-top'>
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset',
grantStatusToneClass(status)
)}
>
{status}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
<p className='text-muted-foreground mt-3 text-[10px]'>
{t(
'Plaintext credentials are never shown. The secret_ref column is a vault pointer, not the secret itself.'
)}
</p>
</div>
)}
</div>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground flex items-center gap-2 text-xs font-semibold'>
<GitCommit className='text-primary h-3.5 w-3.5' />
{t('Events usage')}
</p>
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
{t('Run events hint')}
</p>
</div>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground flex items-center gap-2 text-xs font-semibold'>
<ShieldCheck className='text-primary h-3.5 w-3.5' />
{t('Audit usage')}
</p>
<p className='text-muted-foreground mt-2 text-xs leading-relaxed'>
{t('Run audit hint')}
</p>
</div>
</div>
</div>
)
}
// =============================================================================
// Deployments page
// =============================================================================
export function AgnetDeploymentsPage() {
const { t } = useTranslation()
const [filter, setFilter] = useState<'all' | StatusKey>('all')
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [selectedRunId, setSelectedRunId] = useState<string | undefined>()
const {
data = [],
isLoading,
error: deploymentsError,
refetch: refetchDeployments,
} = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
refetchInterval: 30_000,
retry: false, // QueryState handles error display; no silent retries
})
const filtered = useMemo(() => {
return data.filter((dep) => {
const status = classifyStatus(dep.status || dep.phase || '')
if (filter !== 'all' && status !== filter) return false
if (keyword.trim()) {
const k = keyword.toLowerCase()
const blob =
`${dep.deployment_id} ${dep.orchestration_plan?.objective || ''} ${
dep.orchestration_plan?.metadata?.tenant_id || ''
} ${
dep.orchestration_plan?.agents
?.flatMap((agent) => agent.resource_grants || [])
.map((grant) => grant.binding_scope || '')
.join(' ') || ''
}`.toLowerCase()
if (!blob.includes(k)) return false
}
return true
})
}, [data, filter, keyword])
const selectedRun =
filtered.find((dep) => dep.deployment_id === selectedRunId) ?? filtered[0]
return (
<>
<PageSurface
title={t('Task overview')}
subtitle={t(
'Status, latest activity and last update for every Agnet task you launched. Details live in the desktop client.'
)}
toolbar={
<>
<Button
type='button'
size='sm'
className='h-9 gap-1.5 rounded-xl'
onClick={() => setCreateOpen(true)}
>
<Plus className='h-3.5 w-3.5' />
{t('New run')}
</Button>
<div className='relative'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2' />
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t('Find run / scope / objective')}
className='h-9 w-64 rounded-xl pl-8 text-xs'
/>
</div>
<Select
value={filter}
onValueChange={(value) => setFilter(value as 'all' | StatusKey)}
>
<SelectTrigger className='h-9 w-36 rounded-xl text-xs'>
<Filter className='mr-1 h-3.5 w-3.5' />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>{t('All statuses')}</SelectItem>
<SelectItem value='running'>{t('Running')}</SelectItem>
<SelectItem value='success'>{t('Success')}</SelectItem>
<SelectItem value='failed'>{t('Failed')}</SelectItem>
<SelectItem value='pending'>{t('Pending')}</SelectItem>
</SelectContent>
</Select>
</>
}
>
<QueryState
isLoading={isLoading}
error={deploymentsError}
isEmpty={filtered.length === 0}
retry={() => void refetchDeployments()}
loadingFallback={<LoadingGrid rows={4} height='h-32' />}
emptyTitle={t('No runs match the current filter')}
emptyDescription={t(
'Adjust filters or trigger a new orchestration plan.'
)}
>
<div className='grid gap-3 sm:grid-cols-2'>
{filtered.map((dep) => {
const phase = dep.phase || dep.status
const objective =
dep.orchestration_plan?.objective ||
dep.orchestration_plan?.template_hint ||
t('No objective')
const selected = dep.deployment_id === selectedRun?.deployment_id
return (
<article
key={dep.deployment_id}
tabIndex={0}
role='button'
aria-pressed={selected}
onClick={() => setSelectedRunId(dep.deployment_id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setSelectedRunId(dep.deployment_id)
}
}}
className={cn(
'group hover:border-primary/45 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 rounded-2xl border bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4 transition focus-visible:ring-2 focus-visible:outline-none',
selected
? 'border-primary/55'
: 'border-[color-mix(in_oklch,var(--primary)_18%,var(--border))]'
)}
>
<header className='flex items-start justify-between gap-2'>
<p className='line-clamp-2 text-sm font-medium'>
{objective}
</p>
<StatusBadge phase={phase} />
</header>
<footer className='mt-auto flex items-center justify-between border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-3'>
<span className='text-muted-foreground text-[11px]'>
{formatRelativeTime(dep.updated_at || dep.created_at)}
</span>
<Button
asChild
size='sm'
variant='ghost'
className='text-primary gap-1'
>
<Link to='/events'>
{t('View activity')}
<ArrowUpRight className='h-3.5 w-3.5' />
</Link>
</Button>
</footer>
</article>
)
})}
</div>
</QueryState>
</PageSurface>
<CreateAgnetDeploymentSheet
open={createOpen}
onOpenChange={setCreateOpen}
/>
{/* M9 — task detail drawer. Click a deployment card → audit
timeline + permission manifest fold render here. selectedRun
is derived from selectedRunId; the drawer mirrors that
source-of-truth and closes by clearing the id. */}
<Sheet
open={Boolean(selectedRunId) && Boolean(selectedRun)}
onOpenChange={(o) => {
if (!o) setSelectedRunId(undefined)
}}
>
<SheetContent className='w-[min(720px,96vw)] overflow-y-auto sm:max-w-none'>
{selectedRun && (
<>
<SheetHeader>
<SheetTitle className='text-base'>
{selectedRun.orchestration_plan?.objective ||
selectedRun.orchestration_plan?.template_hint ||
selectedRun.deployment_id}
</SheetTitle>
<SheetDescription className='font-mono text-[11px]'>
{selectedRun.deployment_id}
</SheetDescription>
</SheetHeader>
<div className='mt-4 space-y-4'>
<RunDetailPanel dep={selectedRun} />
<RunAuditTimeline deploymentId={selectedRun.deployment_id} />
<RunRelatedRecordsPanel
deploymentId={selectedRun.deployment_id}
/>
</div>
</>
)}
</SheetContent>
</Sheet>
</>
)
}
// RunAuditTimeline — M9. Renders the full audit-event stream for a
// single deployment, oldest-first (chronological). Data is the
// persistent audit table from Sprint 1; an empty list means either
// the deployment is too fresh to have generated audit rows yet, or
// the server lost connectivity to PostgreSQL (we log + swallow).
//
// Visual: vertical timeline with a coloured dot per event, the event
// name on top, then occurred_at + correlation_id below in muted text.
// "Error" and "rejected" events get a red dot; everything else uses
// the primary tone. Keeps the drawer skim-friendly under heavy
// timelines (60+ events) by setting max-height + overflow.
function RunAuditTimeline({ deploymentId }: { deploymentId: string }) {
const { t } = useTranslation()
const { data = [], isLoading } = useQuery({
queryKey: ['agnet', 'deployment-events', deploymentId],
queryFn: () => getAgnetDeploymentEvents(deploymentId),
enabled: Boolean(deploymentId),
refetchInterval: 15_000,
})
return (
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
<div className='flex items-center justify-between'>
<div>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Audit timeline')}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Every observable transition for this deployment. Survives container restarts (stored in DB).'
)}
</p>
</div>
<span className='text-primary inline-flex items-center rounded-full border border-[color-mix(in_oklch,var(--primary)_25%,var(--border))] bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-2 py-0.5 font-mono text-[10px]'>
{data.length} {t('events')}
</span>
</div>
{isLoading ? (
<div className='mt-4 space-y-2'>
<Skeleton className='h-6 w-full' />
<Skeleton className='h-6 w-5/6' />
<Skeleton className='h-6 w-2/3' />
</div>
) : data.length === 0 ? (
<p className='text-muted-foreground mt-4 text-xs italic'>
{t('No audit events yet for this deployment.')}
</p>
) : (
<ol className='mt-4 max-h-[400px] space-y-2 overflow-y-auto pe-1'>
{data.map((entry, idx) => {
const eventName = String(entry.event || entry.action || '(unknown)')
const occurred = String(entry.occurred_at || '')
const correlation = String(entry.correlation_id || '')
const level = classifyEventLevel(entry)
const dotClass =
level === 'error'
? 'bg-rose-500 ring-rose-500/30'
: level === 'warn'
? 'bg-amber-500 ring-amber-500/30'
: 'bg-primary ring-primary/30'
return (
<li
key={
(entry.event_id as string) ||
`${eventName}-${idx}-${occurred}`
}
className='bg-background/40 flex items-start gap-3 rounded-lg border border-dashed border-[color-mix(in_oklch,var(--primary)_14%,var(--border))] p-2'
>
<span
className={cn(
'mt-1 inline-block h-2.5 w-2.5 shrink-0 rounded-full ring-2',
dotClass
)}
/>
<div className='min-w-0 flex-1'>
<p className='text-foreground font-mono text-xs'>
{eventName}
</p>
<p className='text-muted-foreground mt-0.5 text-[11px]'>
{occurred || '—'}
{correlation && (
<>
<span className='mx-2'>·</span>
<span className='font-mono'>{correlation}</span>
</>
)}
</p>
</div>
</li>
)
})}
</ol>
)}
</div>
)
}
function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
const { t } = useTranslation()
const { data, isLoading } = useQuery({
queryKey: ['agnet', 'deployment-timeline', deploymentId],
queryFn: () => getAgnetDeploymentTimeline(deploymentId),
enabled: Boolean(deploymentId),
refetchInterval: 15_000,
})
const callbacks = data?.callbacks ?? []
const artifacts = data?.artifacts ?? []
const snapshots = data?.sk_snapshots ?? []
const timeline = data?.timeline ?? []
return (
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Related records')}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Callbacks, artifacts, SK snapshots and merged timeline')}
</p>
</div>
{isLoading ? (
<CircleDashed className='text-muted-foreground h-4 w-4 animate-spin' />
) : null}
</div>
<div className='mt-4 grid gap-2 sm:grid-cols-4'>
<MetaPill
icon={GitCommit}
label={t('callbacks')}
value={String(callbacks.length)}
/>
<MetaPill
icon={FileSearch}
label={t('artifacts')}
value={String(artifacts.length)}
/>
<MetaPill
icon={ShieldCheck}
label='SK'
value={String(snapshots.length)}
/>
<MetaPill
icon={Activity}
label={t('timeline')}
value={String(timeline.length)}
/>
</div>
<div className='mt-4 grid gap-3 md:grid-cols-2'>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground text-xs font-semibold'>
{t('Artifacts')}
</p>
{artifacts.length === 0 ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t('No artifacts yet')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{artifacts.slice(0, 5).map((item, idx) => (
<li
key={String(item.artifact_id || idx)}
className='bg-muted/25 rounded-lg p-2'
>
<p className='truncate text-xs font-medium'>
{String(item.title || item.artifact_id || t('Artifact'))}
</p>
<p className='text-muted-foreground mt-1 line-clamp-2 text-[11px]'>
{String(item.summary || item.uri || '—')}
</p>
</li>
))}
</ul>
)}
</div>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground text-xs font-semibold'>
{t('SK snapshots')}
</p>
{snapshots.length === 0 ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t('No SK snapshots yet')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{snapshots.slice(0, 5).map((item, idx) => (
<li
key={String(item.snapshot_id || idx)}
className='bg-muted/25 rounded-lg p-2'
>
<p className='truncate font-mono text-[11px]'>
{String(item.snapshot_id || 'snapshot')}
</p>
<p className='text-muted-foreground mt-1 truncate text-[11px]'>
{String(item.source_type || 'source')} ·{' '}
{String(item.source_ref || '—')}
</p>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// Events page
// =============================================================================
const EVENT_LEVELS = ['all', 'info', 'warn', 'error'] as const
type EventLevel = (typeof EVENT_LEVELS)[number]
function classifyEventLevel(entry: Record<string, unknown>): EventLevel {
const candidate = String(
entry.level || entry.severity || entry.status || ''
).toLowerCase()
if (
candidate.includes('error') ||
candidate.includes('fail') ||
candidate.includes('rejected')
)
return 'error'
if (candidate.includes('warn')) return 'warn'
if (!candidate) return 'info'
return 'info'
}
export function AgnetEventsPage() {
const { t } = useTranslation()
const [level, setLevel] = useState<EventLevel>('all')
const deploymentsQuery = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
})
const deployments = deploymentsQuery.data ?? []
const [activeDeployment, setActiveDeployment] = useState<string | undefined>(
undefined
)
const effectiveDeployment = activeDeployment ?? deployments[0]?.deployment_id
const eventsQuery = useQuery({
queryKey: ['agnet', 'events', effectiveDeployment],
queryFn: () => getAgnetDeploymentEvents(effectiveDeployment as string),
enabled: Boolean(effectiveDeployment),
})
const filteredEvents = useMemo(() => {
const items = eventsQuery.data ?? []
if (level === 'all') return items
return items.filter((entry) => classifyEventLevel(entry) === level)
}, [eventsQuery.data, level])
return (
<PageSurface
title={t('Events')}
subtitle={t(
'Lifecycle and policy events emitted by Agnet deployments. Correlate with deployment cards.'
)}
toolbar={
<>
<Select
value={effectiveDeployment ?? ''}
onValueChange={setActiveDeployment}
disabled={deployments.length === 0}
>
<SelectTrigger className='h-9 w-60 rounded-xl text-xs'>
<Rocket className='mr-1 h-3.5 w-3.5' />
<SelectValue placeholder={t('Select deployment')} />
</SelectTrigger>
<SelectContent>
{deployments.map((dep) => (
<SelectItem key={dep.deployment_id} value={dep.deployment_id}>
<span className='font-mono text-xs'>{dep.deployment_id}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={level}
onValueChange={(value) => setLevel(value as EventLevel)}
>
<SelectTrigger className='h-9 w-32 rounded-xl text-xs'>
<Filter className='mr-1 h-3.5 w-3.5' />
<SelectValue />
</SelectTrigger>
<SelectContent>
{EVENT_LEVELS.map((lv) => (
<SelectItem key={lv} value={lv}>
{lv}
</SelectItem>
))}
</SelectContent>
</Select>
</>
}
>
{!effectiveDeployment ? (
<EmptySurface
title={t('No deployment available for events.')}
hint={t('Trigger a deployment to start emitting events.')}
/>
) : (
<QueryState
isLoading={eventsQuery.isLoading}
error={eventsQuery.error}
isEmpty={filteredEvents.length === 0}
retry={() => void eventsQuery.refetch()}
loadingFallback={<LoadingGrid rows={4} height='h-16' />}
emptyTitle={t('No events for the current filter.')}
emptyDescription={t('Switch level or pick another deployment.')}
>
<ol className='relative ms-2 space-y-4 border-s border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] ps-4'>
{filteredEvents.map((entry, idx) => {
const lv = classifyEventLevel(entry as Record<string, unknown>)
const action = String(
(entry as Record<string, unknown>).event ||
(entry as Record<string, unknown>).type ||
'event'
)
const occurred = String(
(entry as Record<string, unknown>).occurred_at ||
(entry as Record<string, unknown>).timestamp ||
''
)
const dot =
lv === 'error'
? 'bg-rose-400 ring-rose-500/30'
: lv === 'warn'
? 'bg-amber-400 ring-amber-500/30'
: 'bg-primary ring-primary/30'
const Icon =
lv === 'error'
? AlertOctagon
: lv === 'warn'
? Activity
: GitCommit
return (
<li key={idx} className='relative'>
<span
className={cn(
'absolute top-1.5 -left-[21px] inline-block h-2 w-2 rounded-full ring-[3px]',
dot
)}
/>
<div className='flex items-center gap-2'>
<Icon className='text-primary h-3.5 w-3.5' />
<p className='text-primary font-mono text-[11px] tracking-[0.14em] uppercase'>
{action}
</p>
<span className='text-muted-foreground ml-auto text-[11px]'>
{formatRelativeTime(occurred)}
</span>
</div>
<pre className='text-muted-foreground mt-1 overflow-auto rounded-lg bg-[color-mix(in_oklch,var(--card)_45%,transparent)] p-2 text-[11px] leading-relaxed'>
{JSON.stringify(entry, null, 2)}
</pre>
</li>
)
})}
</ol>
</QueryState>
)}
</PageSurface>
)
}
// =============================================================================
// Audit page
// =============================================================================
// =============================================================================
// Audit — docs §10 §"任务用量与审计":
// "默认作为任务详情里的抽屉或浮层,不作为主体验。" 这里因为路由独立保留,
// 但视觉做成"抽屉式"分组卡片,所有敏感字段统一脱敏成 secret_ref / hash。
// 禁止展示:
// - 明文密钥(password, access key, token, private key)
// - 完整 payload / permission manifest
// - 用户填的底层 ID(除非分类用途)
// =============================================================================
const SECRET_FIELD_HINTS = [
'token',
'password',
'key',
'secret',
'credential',
'apikey',
'api_key',
'access_key',
] as const
/** Heuristic redaction for any unexpected secret-shaped field showing up
* in the audit payload. The backend SHOULD never emit these, but defense
* in depth — docs §6 禁止前端返回明文密钥. */
function maskIfSecret(key: string, value: string): string {
const lk = key.toLowerCase()
if (SECRET_FIELD_HINTS.some((h) => lk.includes(h))) {
if (!value) return '—'
if (
value.startsWith('secret_ref:') ||
value.startsWith('azkv:') ||
value.startsWith('vault:')
) {
return value
}
return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-2)}` : '***'
}
return value
}
function classifyAuditAction(action: string): {
tone: 'risk' | 'change' | 'info'
label: string
} {
const a = action.toLowerCase()
if (
a.includes('approve') ||
a.includes('deploy') ||
a.includes('production') ||
a.includes('delete')
) {
return { tone: 'risk', label: action }
}
if (
a.includes('grant') ||
a.includes('revoke') ||
a.includes('rotate') ||
a.includes('lease')
) {
return { tone: 'change', label: action }
}
return { tone: 'info', label: action }
}
function AuditEntryCard({
entry,
t,
}: {
entry: Record<string, unknown>
t: ReturnType<typeof useTranslation>['t']
}) {
const action = String(entry.action || entry.event || '—')
const actor = String(entry.actor || entry.user || '—')
const scope = String(
entry.binding_scope || entry.tenant || entry.tenant_id || '—'
)
const resourceId = String(entry.resource_id || '—')
const resourceType = String(entry.resource_type || '—')
const allowedActions = Array.isArray(entry.allowed_actions)
? (entry.allowed_actions as string[]).join(', ')
: String(entry.allowed_actions || '—')
const constraints = entry.constraints
? typeof entry.constraints === 'string'
? entry.constraints
: JSON.stringify(entry.constraints)
: '—'
const secretRef = String(entry.secret_ref || '—')
const occurred = String(entry.occurred_at || entry.timestamp || '')
const { tone, label } = classifyAuditAction(action)
const toneCls: Record<typeof tone, string> = {
risk: 'border-rose-500/30 bg-rose-500/5',
change: 'border-amber-500/30 bg-amber-500/5',
info: 'border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/40',
}
return (
<article
className={cn(
'rounded-2xl border p-4 transition hover:-translate-y-px',
toneCls[tone]
)}
>
<header className='flex flex-wrap items-start justify-between gap-3'>
<div>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{tone === 'risk'
? t('High-risk action')
: tone === 'change'
? t('Scope / credential change')
: t('Audit event')}
</p>
<p className='text-foreground mt-1 font-mono text-sm font-semibold'>
{label}
</p>
</div>
<span className='text-muted-foreground text-[11px]'>
{formatRelativeTime(occurred)}
</span>
</header>
<dl className='mt-3 grid gap-2 text-xs sm:grid-cols-2'>
<RedactedField k='actor' v={actor} />
<RedactedField k='scope' v={scope} />
<RedactedField k='resource_id' v={resourceId} />
<RedactedField k='resource_type' v={resourceType} />
<RedactedField k='allowed_actions' v={allowedActions} />
<RedactedField k='constraints' v={constraints} />
<RedactedField
k='secret_ref'
v={maskIfSecret('secret_ref', secretRef)}
mono
/>
</dl>
</article>
)
}
function RedactedField({
k,
v,
mono,
}: {
k: string
v: string
mono?: boolean
}) {
return (
<div className='flex items-start gap-2'>
<dt className='text-muted-foreground shrink-0 text-[10px] font-medium tracking-[0.12em] uppercase'>
{k}
</dt>
<dd
className={cn(
'text-foreground/90 min-w-0 break-all',
mono && 'font-mono text-[11px]'
)}
>
{v}
</dd>
</div>
)
}
function formatUnixMs(value?: number) {
if (!value) return '—'
return new Date(value).toLocaleString()
}
function AgnetApprovalCard({
approval,
approveBusy,
rejectBusy,
onApprove,
onReject,
}: {
approval: AgnetApprovalRequest
approveBusy: boolean
rejectBusy: boolean
onApprove: () => void
onReject: () => void
}) {
return (
<article className='border-border/70 bg-background/45 rounded-xl border p-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='font-mono text-xs font-semibold break-all'>
{approval.operation}
</p>
<p className='text-muted-foreground mt-1 text-xs break-all'>
{approval.resource_type}:{approval.resource_id} ·{' '}
{approval.target_role}
</p>
</div>
<StatusBadge phase={approval.risk_level} />
</div>
<dl className='mt-3 grid gap-2 text-xs sm:grid-cols-2'>
<RedactedField k='scope' v={approval.binding_scope || '—'} />
<RedactedField k='resource_scope' v={approval.resource_scope || '—'} />
<RedactedField k='expires_at' v={formatUnixMs(approval.expires_at)} />
<RedactedField
k='credential'
v={approval.requires_credential ? 'required' : 'not required'}
/>
</dl>
<div className='mt-3 flex flex-wrap justify-end gap-2'>
<Button
size='sm'
variant='outline'
disabled={rejectBusy || approveBusy}
onClick={onReject}
>
Reject
</Button>
<Button
size='sm'
disabled={approveBusy || rejectBusy}
onClick={onApprove}
>
Approve
</Button>
</div>
</article>
)
}
function AgnetLeaseCard({
lease,
busy,
onRevoke,
}: {
lease: AgnetCredentialLease
busy: boolean
onRevoke: () => void
}) {
return (
<article className='border-border/70 bg-background/45 rounded-xl border p-3'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='font-mono text-xs font-semibold break-all'>
{lease.credential_ref}
</p>
<p className='text-muted-foreground mt-1 text-xs break-all'>
{lease.resource_type}:{lease.resource_id} · {lease.target_role}
</p>
</div>
<StatusBadge phase={lease.status} />
</div>
<dl className='mt-3 grid gap-2 text-xs'>
<RedactedField k='approval' v={lease.approval_id} mono />
<RedactedField k='expires_at' v={formatUnixMs(lease.expires_at)} />
</dl>
<div className='mt-3 flex justify-end'>
<Button size='sm' variant='outline' disabled={busy} onClick={onRevoke}>
Revoke
</Button>
</div>
</article>
)
}
export function AgnetAuditPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [scope, setScope] = useState('')
const [actor, setActor] = useState('')
const [actionFilter, setActionFilter] = useState('')
const approvalsQuery = useQuery({
queryKey: ['agnet', 'approvals', 'pending'],
queryFn: () => listAgnetApprovals({ status: 'pending' }),
refetchInterval: 30_000,
})
const leasesQuery = useQuery({
queryKey: ['agnet', 'credential-leases', 'active'],
queryFn: () => listAgnetCredentialLeases({ status: 'active' }),
refetchInterval: 30_000,
})
const refreshApprovalState = () => {
void queryClient.invalidateQueries({ queryKey: ['agnet', 'approvals'] })
void queryClient.invalidateQueries({
queryKey: ['agnet', 'credential-leases'],
})
void queryClient.invalidateQueries({
queryKey: ['heicode', 'agnet', 'audit'],
})
}
const approveMutation = useMutation({
mutationFn: (approvalId: string) =>
approveAgnetApproval(approvalId, t('Approved from Manager audit page')),
onSuccess: () => {
toast.success(t('Approval accepted'))
refreshApprovalState()
},
})
const rejectMutation = useMutation({
mutationFn: (approvalId: string) =>
rejectAgnetApproval(approvalId, t('Rejected from Manager audit page')),
onSuccess: () => {
toast.success(t('Approval rejected'))
refreshApprovalState()
},
})
const revokeMutation = useMutation({
mutationFn: (leaseId: string) =>
revokeAgnetCredentialLease(leaseId, t('Revoked from Manager audit page')),
onSuccess: () => {
toast.success(t('Credential lease revoked'))
refreshApprovalState()
},
})
const {
data = [],
isLoading,
error: auditError,
refetch: refetchAudit,
} = useQuery({
queryKey: ['heicode', 'agnet', 'audit'],
queryFn: () => listMcpAuditLogs({ limit: 200 }),
refetchInterval: 60_000,
retry: false,
})
const filtered = useMemo(() => {
return data.filter((entry) => {
const e = entry as Record<string, unknown>
const s = String(
e.binding_scope || e.tenant || e.tenant_id || ''
).toLowerCase()
const a = String(e.actor || e.user || '').toLowerCase()
const ac = String(e.action || e.event || '').toLowerCase()
if (scope && !s.includes(scope.toLowerCase())) return false
if (actor && !a.includes(actor.toLowerCase())) return false
if (actionFilter && !ac.includes(actionFilter.toLowerCase())) return false
return true
})
}, [data, scope, actor, actionFilter])
return (
<PageSurface
title={t('Audit')}
subtitle={t(
'Approvals, scope changes and credential rotations. Plaintext secrets are never shown — only secret_ref and redacted summaries.'
)}
toolbar={
<>
<Input
value={scope}
onChange={(e) => setScope(e.target.value)}
placeholder={t('scope')}
className='h-9 w-36 rounded-xl text-xs'
/>
<Input
value={actor}
onChange={(e) => setActor(e.target.value)}
placeholder={t('actor')}
className='h-9 w-36 rounded-xl text-xs'
/>
<Input
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
placeholder={t('action')}
className='h-9 w-36 rounded-xl text-xs'
/>
</>
}
>
<p className='bg-background/30 text-muted-foreground rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3 text-[11px]'>
{t(
'Per docs §6: plaintext credentials never appear here. Long-lived secrets live in the secret vault; only secret_ref and redacted previews are shown.'
)}
</p>
<section className='grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]'>
<div className='border-border/70 bg-card/70 rounded-xl border p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div>
<h3 className='text-sm font-semibold'>
{t('Pending approvals')}
</h3>
<p className='text-muted-foreground text-xs'>
{t(
'Approve or reject high-risk Agnet operations before credentials are leased.'
)}
</p>
</div>
<StatusBadge phase='pending' />
</div>
<QueryState
isLoading={approvalsQuery.isLoading}
error={approvalsQuery.error}
isEmpty={(approvalsQuery.data ?? []).length === 0}
retry={() => void approvalsQuery.refetch()}
loadingFallback={<LoadingGrid rows={2} height='h-24' />}
emptyTitle={t('No pending approvals')}
emptyDescription={t('High-risk operations will appear here.')}
>
<div className='grid gap-3'>
{(approvalsQuery.data ?? []).map((approval) => (
<AgnetApprovalCard
key={approval.approval_id}
approval={approval}
approveBusy={approveMutation.isPending}
rejectBusy={rejectMutation.isPending}
onApprove={() => approveMutation.mutate(approval.approval_id)}
onReject={() => rejectMutation.mutate(approval.approval_id)}
/>
))}
</div>
</QueryState>
</div>
<div className='border-border/70 bg-card/70 rounded-xl border p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div>
<h3 className='text-sm font-semibold'>
{t('Active credential leases')}
</h3>
<p className='text-muted-foreground text-xs'>
{t(
'Only short-lived lease references are shown. Revoke after the task ends.'
)}
</p>
</div>
<ShieldCheck className='text-primary size-4' />
</div>
<QueryState
isLoading={leasesQuery.isLoading}
error={leasesQuery.error}
isEmpty={(leasesQuery.data ?? []).length === 0}
retry={() => void leasesQuery.refetch()}
loadingFallback={<LoadingGrid rows={2} height='h-24' />}
emptyTitle={t('No active credential leases')}
emptyDescription={t('Approved credential leases will appear here.')}
>
<div className='grid gap-3'>
{(leasesQuery.data ?? []).map((lease) => (
<AgnetLeaseCard
key={lease.lease_id}
lease={lease}
busy={revokeMutation.isPending}
onRevoke={() => revokeMutation.mutate(lease.lease_id)}
/>
))}
</div>
</QueryState>
</div>
</section>
<QueryState
isLoading={isLoading}
error={auditError}
isEmpty={filtered.length === 0}
retry={() => void refetchAudit()}
loadingFallback={<LoadingGrid rows={4} height='h-32' />}
emptyTitle={t('No audit entries match the filter.')}
emptyDescription={t('Reset filters to see all entries.')}
>
<div className='grid gap-3'>
{filtered.map((entry, idx) => (
<AuditEntryCard
key={idx}
entry={entry as Record<string, unknown>}
t={t}
/>
))}
</div>
</QueryState>
</PageSurface>
)
}
// =============================================================================
// Resources — keep Git-source API wiring while presenting it as the first
// resource-binding slice for Work/Runs.
// =============================================================================
// AgnetSKSourcesPage — “准备清单” wizard. Frames the page as a 4-step list
// (代码 / 文档 / 云账号 / 推荐摘要) per docs/product-package/10 §"Manager 准备清单"
// + /11 §5. Does not expose repo_url / ref / paths / usage / tenant_id as the
// main flow — those move into a “手动补充”次级 sheet only opened when the user
// clicks “连接代码仓库 → 高级补充”.
// AgnetSKSourcesPage — 准备清单 wizard.
//
// Data layer switched (commit ?) from the Heicode-local git_sources controller
// to mcp-server §2 ResourceBinding (/api/resources) per the contract docs
// §2/§3 and Heicode-对接进度与待办.md §7.5 point 7 ("Manager 团队补上 —
// 否则用户从客户端任务卡点 '去 Manager 准备' 按钮过去后会 404").
//
// Field mapping for the advanced "manual entry" sheet:
// old GitSourcePayload → mcp-server ResourceBinding
// name → name
// repo_url → external_ref
// provider → metadata.provider
// ref → metadata.default_branch + constraints.ref
// paths (string[]) → constraints.allowed_paths (comma-joined)
// usage ('project'|'sk'|...) → type ('git' for project, 'sk' for SK,
// 'project_doc' for docs)
// tenant_id → (dropped — server uses auth.user_id)
// (none) → permission_scope ['repo:read']
// (none) → secret_ref (Azure Key Vault azkv://...
// reference when the resource has
// credential material)
export function AgnetSKSourcesPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [advancedOpen, setAdvancedOpen] = useState(false)
// M2 phase 1 — Azure cloud binding sheet. Separate from advancedOpen
// so the cloud step uses its own provider-specific form instead of
// the generic resource entry sheet (which is meant for git/sk/doc).
const [azureSheetOpen, setAzureSheetOpen] = useState(false)
const [resourceForm, setResourceForm] = useState<{
name: string
type: ResourceType
external_ref: string
provider: string
ref: string
allowed_paths: string
}>({
name: '',
type: 'git',
external_ref: '',
provider: 'github',
ref: 'main',
allowed_paths: '.',
})
const resourcesQuery = useQuery({
queryKey: ['heicode', 'resources'],
queryFn: () =>
listResources({ status: 'active', limit: 200 }).then((r) => r.items),
// Empty list when mcp-server unreachable is OK — UI degrades gracefully.
retry: false,
})
const resources = resourcesQuery.data ?? []
const managerCloudResourcesQuery = useQuery({
queryKey: ['manager', 'cloud-resources'],
queryFn: () =>
listManagerResources({ status: 'active' }).then((items) =>
items.filter(
(r) =>
r.resource_type === 'cloud_account' ||
r.resource_type === 'cloud_resource'
)
),
retry: false,
})
const secretStoreQuery = useQuery({
queryKey: ['secret-store', 'status'],
queryFn: async () => {
const res = await api.get<{
success: boolean
data?: SecretStoreStatus
}>('/api/secret-store/status', {
skipBusinessError: true,
skipErrorHandler: true,
} as Record<string, unknown>)
return res.data?.data ?? { configured: false, reachable: false }
},
retry: false,
})
const keyVaultReady = Boolean(
secretStoreQuery.data?.configured && secretStoreQuery.data?.reachable
)
const createResourceMutation = useMutation({
mutationFn: () => {
const isGit = resourceForm.type === 'git'
return createResource({
type: resourceForm.type,
name: resourceForm.name.trim(),
external_ref: resourceForm.external_ref.trim() || undefined,
metadata: isGit
? {
provider: resourceForm.provider,
default_branch: resourceForm.ref,
}
: {},
permission_scope: isGit ? ['repo:read'] : [],
constraints: isGit
? {
ref: resourceForm.ref,
allowed_paths: resourceForm.allowed_paths
.split(/[\n,]/)
.map((x) => x.trim())
.filter(Boolean)
.join(','),
}
: {},
status: 'active',
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['heicode', 'resources'] })
setResourceForm((prev) => ({ ...prev, name: '', external_ref: '' }))
},
})
const revokeResourceMutation = useMutation({
mutationFn: (id: string) => revokeResource(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['heicode', 'resources'] })
},
})
const [summaryOpen, setSummaryOpen] = useState(false)
const projectSources = resources.filter((r) => r.type === 'git')
// M3 — project_doc is now a first-class binding type with its own
// step, separated from SK skill packs. The docs/04 §"绑定资源"
// contract lists "项目文档" as a distinct category alongside Git
// and SK; the previous merged "SK or project docs" step blurred
// that line and made users wonder which one they were picking.
const docSources = resources.filter((r) => r.type === 'project_doc')
const skSources = resources.filter((r) => r.type === 'sk')
const mcpCloudSources = resources.filter(
(r) => r.type === 'cloud_account' || r.type === 'cloud_resource'
)
const cloudSources = [
...mcpCloudSources,
...(managerCloudResourcesQuery.data ?? []),
]
const managerCloudAccounts = (managerCloudResourcesQuery.data ?? []).filter(
(r) => r.resource_type === 'cloud_account'
)
const managerDiscoveredCloudResources = (
managerCloudResourcesQuery.data ?? []
).filter((r) => r.resource_type === 'cloud_resource')
// skSources used to include project_doc rows for "do we have any
// doc-ish source" gating downstream — preserve that contract by
// exposing a combined view for any callers that still want it.
const skOrDocSources = [...skSources, ...docSources]
const steps = [
{
key: 'code',
title: t('Connect code repository'),
summary:
projectSources.length > 0
? t('{{n}} repository connected', { n: projectSources.length })
: t('Authorize GitHub / GitLab / Gitee / self-hosted Git'),
done: projectSources.length > 0,
},
{
key: 'docs',
title: t('Connect project docs'),
summary:
docSources.length > 0
? t('{{n}} doc source connected', { n: docSources.length })
: t(
'Link product requirements, design docs or wiki repos so Agnet has project context.'
),
done: docSources.length > 0,
optional: true,
},
{
key: 'sk',
title: t('Connect SK skill packs'),
summary:
skSources.length > 0
? t('{{n}} SK source connected', { n: skSources.length })
: t('Pick a reusable skill / agent toolset repository, or skip.'),
done: skSources.length > 0,
optional: true,
},
{
key: 'cloud',
title: t('Connect cloud account'),
summary:
managerDiscoveredCloudResources.length > 0
? t('{{n}} cloud resource connected', {
n: managerDiscoveredCloudResources.length,
})
: managerCloudAccounts.length > 0
? t('{{n}} cloud account connected', {
n: managerCloudAccounts.length,
})
: t(
'Authorize Azure (AWS / GCP coming soon). Heicode auto-discovers cloud resources.'
),
done: cloudSources.length > 0,
},
{
key: 'review',
title: t('Confirm recommendation summary'),
summary: t(
'Heicode generates the parameters automatically. You only confirm allowed scope and risk.'
),
done: false,
cta: true,
},
]
const completed = steps.filter((s) => s.done).length
const total = steps.length
const prereqsDone = projectSources.length > 0 || skOrDocSources.length > 0
return (
<PageSurface
title={t('Preparation checklist')}
subtitle={t(
'Connect code, docs and cloud resources for the current task, then confirm the recommendation before launching Agnet.'
)}
toolbar={
<span className='text-primary inline-flex items-center gap-1.5 rounded-full border border-[color-mix(in_oklch,var(--primary)_30%,var(--border))] bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-3 py-1 text-[11px] font-semibold tracking-[0.12em] uppercase'>
<CheckCircle2 className='h-3 w-3' />
{t('{{done}}/{{total}} completed', { done: completed, total })}
</span>
}
>
<ol className='space-y-3'>
{steps.map((step, idx) => (
<li
key={step.key}
className='flex items-start gap-4 rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'
>
<span
className={cn(
'mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold ring-1 ring-inset',
step.done
? 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30'
: 'text-primary ring-primary/30 bg-[color-mix(in_oklch,var(--primary)_12%,transparent)]'
)}
>
{step.done ? <CheckCircle2 className='h-3.5 w-3.5' /> : idx + 1}
</span>
<div className='min-w-0 flex-1'>
<p className='text-foreground text-sm font-semibold'>
{step.title}
{step.optional && (
<span className='text-muted-foreground ms-2 text-[10px] font-medium tracking-[0.12em] uppercase'>
{t('optional')}
</span>
)}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{step.summary}
</p>
</div>
{step.key === 'code' || step.key === 'docs' || step.key === 'sk' ? (
<Button
type='button'
variant={step.done ? 'ghost' : 'default'}
size='sm'
className='shrink-0 rounded-xl'
onClick={() => {
// Pre-select the right resource type so users
// don't accidentally bind a doc as a git source.
const presetType =
step.key === 'docs'
? 'project_doc'
: step.key === 'sk'
? 'sk'
: 'git'
setResourceForm((v) => ({ ...v, type: presetType }))
setAdvancedOpen(true)
}}
>
{step.done ? t('Manage') : t('Connect')}
</Button>
) : step.key === 'review' ? (
<Button
type='button'
size='sm'
className='shrink-0 rounded-xl'
disabled={!prereqsDone}
onClick={() => setSummaryOpen(true)}
>
{t('Confirm and launch Agnet')}
</Button>
) : step.key === 'cloud' ? (
// Open the Azure-specific sheet. Manager stores the secret in
// Azure Key Vault, then enumerates ARM resources through the
// backend discovery endpoint.
<Button
type='button'
variant={step.done ? 'ghost' : 'default'}
size='sm'
className='shrink-0 rounded-xl'
onClick={() => setAzureSheetOpen(true)}
>
{step.done ? t('Manage') : t('Connect Azure')}
</Button>
) : (
<Button
type='button'
size='sm'
variant='outline'
className='shrink-0 rounded-xl'
disabled
>
{t('Coming soon')}
</Button>
)}
</li>
))}
</ol>
<div className='text-muted-foreground rounded-2xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_40%,transparent)] p-4 text-xs leading-relaxed'>
<p className='text-foreground font-medium'>{t('How this works')}</p>
<p className='mt-2'>
{t(
'Long-lived credentials are stored in the secret vault. Agnet only requests short-lived, scoped credentials at run time. Production deploys and other high-risk actions are approved from the desktop client.'
)}
</p>
</div>
{advancedOpen && (
<div className='bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm'>
<div className='bg-card max-h-[85vh] w-[min(640px,92vw)] overflow-auto rounded-2xl border border-[color-mix(in_oklch,var(--primary)_24%,var(--border))] p-5 shadow-2xl'>
<header className='flex items-start justify-between gap-3 border-b border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pb-3'>
<div>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Advanced — manual entry')}
</p>
<h3 className='mt-1 text-base font-semibold'>
{t('Connect a code or SK repository')}
</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Only needed when auto-discovery cannot find the source. Heicode will store the binding and never expose plaintext credentials.'
)}
</p>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setAdvancedOpen(false)}
>
{t('Close')}
</Button>
</header>
<div className='mt-4 grid gap-3'>
<div className='grid gap-2 sm:grid-cols-2'>
<div>
<label className='text-muted-foreground text-xs font-medium'>
{t('Source name')}
</label>
<Input
value={resourceForm.name}
onChange={(e) =>
setResourceForm((v) => ({ ...v, name: e.target.value }))
}
placeholder='project-main'
className='mt-1 h-9 text-xs'
/>
</div>
<div>
<label className='text-muted-foreground text-xs font-medium'>
{t('Resource type')}
</label>
<Select
value={resourceForm.type}
onValueChange={(type) =>
setResourceForm((v) => ({
...v,
type: type as ResourceType,
}))
}
>
<SelectTrigger className='mt-1 h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='git'>{t('Code (Git)')}</SelectItem>
<SelectItem value='sk'>{t('SK repository')}</SelectItem>
<SelectItem value='project_doc'>
{t('Project docs')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{resourceForm.type === 'git' && (
<div className='grid gap-2 sm:grid-cols-2'>
<div>
<label className='text-muted-foreground text-xs font-medium'>
{t('Provider')}
</label>
<Select
value={resourceForm.provider}
onValueChange={(provider) =>
setResourceForm((v) => ({ ...v, provider }))
}
>
<SelectTrigger className='mt-1 h-9 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='github'>GitHub</SelectItem>
<SelectItem value='gitlab'>GitLab</SelectItem>
<SelectItem value='gitea'>Gitea</SelectItem>
<SelectItem value='gitee'>Gitee</SelectItem>
<SelectItem value='custom'>Custom Git</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className='text-muted-foreground text-xs font-medium'>
{t('Branch')}
</label>
<Input
value={resourceForm.ref}
onChange={(e) =>
setResourceForm((v) => ({ ...v, ref: e.target.value }))
}
className='mt-1 h-9 font-mono text-xs'
/>
</div>
</div>
)}
<div>
<label className='text-muted-foreground text-xs font-medium'>
{resourceForm.type === 'git'
? t('Repository URL')
: t('External reference (URL or doc location)')}
</label>
<Input
value={resourceForm.external_ref}
onChange={(e) =>
setResourceForm((v) => ({
...v,
external_ref: e.target.value,
}))
}
placeholder={
resourceForm.type === 'git'
? 'https://github.com/org/repo.git'
: 'https://example.com/docs'
}
className='mt-1 h-9 font-mono text-xs'
/>
</div>
{resourceForm.type === 'git' && (
<div>
<label className='text-muted-foreground text-xs font-medium'>
{t('Allowed paths')}
</label>
<textarea
value={resourceForm.allowed_paths}
onChange={(e) =>
setResourceForm((v) => ({
...v,
allowed_paths: e.target.value,
}))
}
rows={2}
spellCheck={false}
className='border-input bg-background focus-visible:ring-ring mt-1 w-full rounded-md border px-3 py-2 font-mono text-xs shadow-sm outline-none focus-visible:ring-1'
/>
</div>
)}
<p className='bg-background/40 text-muted-foreground rounded-lg border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3 text-[11px]'>
{t(
'Plaintext credentials are never accepted. Heicode writes the real token to the secret vault and stores only secret_ref.'
)}
</p>
{createResourceMutation.isError && (
<p className='rounded-lg border border-rose-500/30 bg-rose-500/10 p-2 text-xs text-rose-300'>
{(createResourceMutation.error as Error)?.message}
</p>
)}
<Button
type='button'
className='w-fit gap-1.5'
disabled={
createResourceMutation.isPending || !resourceForm.name.trim()
}
onClick={() => createResourceMutation.mutate()}
>
<Plus className='h-3.5 w-3.5' />
{t('Bind source')}
</Button>
</div>
<div className='mt-5 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-4'>
<p className='text-sm font-medium'>{t('Connected sources')}</p>
{resourcesQuery.isLoading ? (
<div className='mt-2 space-y-2'>
<Skeleton className='h-12 rounded-lg' />
<Skeleton className='h-12 rounded-lg' />
</div>
) : resources.length === 0 ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t('No sources connected yet.')}
</p>
) : (
<ul className='mt-2 space-y-2'>
{resources.map((src: ResourceBinding) => (
<li
key={src.id}
className='border-border bg-background/60 flex items-start justify-between gap-3 rounded-lg border p-3'
>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<p className='truncate text-sm font-medium'>
{src.name}
</p>
<span className='text-primary rounded-full bg-[color-mix(in_oklch,var(--primary)_14%,transparent)] px-1.5 py-0.5 text-[9px] font-semibold tracking-wider uppercase'>
{src.type}
</span>
</div>
{src.external_ref && (
<p className='text-muted-foreground mt-0.5 truncate font-mono text-[11px]'>
{src.external_ref}
</p>
)}
</div>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 shrink-0'
disabled={revokeResourceMutation.isPending}
onClick={() => revokeResourceMutation.mutate(src.id)}
>
<Trash2 className='text-destructive h-3.5 w-3.5' />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
{summaryOpen && (
<RecommendationSummaryDialog
onClose={() => setSummaryOpen(false)}
projectSources={projectSources}
// Pre-M3 the summary dialog received a combined SK + docs
// list; preserve that so the summary still shows every
// doc-ish binding even after we split the steps.
skSources={skOrDocSources}
/>
)}
<AzureCloudBindingSheet
open={azureSheetOpen}
onOpenChange={setAzureSheetOpen}
vaultConfigured={keyVaultReady}
/>
</PageSurface>
)
}
// =============================================================================
// 推荐确认卡 — docs/product-package/10 §"推荐确认卡":
// 本次会做 / 本次允许使用 / 本次不会做 / 高危规则 / 预计消耗 / 启动 Agnet
// 「启动 Agnet」旁边写「参数由 Heicode 自动生成」。
// 没有 JSON 编辑器、permission manifest、resource grant 表(§10 高级展开禁令)。
// =============================================================================
function RecommendationSummaryDialog({
onClose,
projectSources,
skSources,
}: {
onClose: () => void
projectSources: ResourceBinding[]
skSources: ResourceBinding[]
}) {
const { t } = useTranslation()
const [launching, setLaunching] = useState(false)
const willDo = [
t('Clarify requirements and draft product brief'),
t('Generate development tasks and check list'),
t('Code, review, test in a sandboxed environment'),
t('Stage deployment artifacts; production deploy needs approval'),
]
const allowedUse = [
projectSources.length > 0
? t('Code: {{n}} repository connected', { n: projectSources.length })
: t('Code: starting from scratch'),
skSources.length > 0
? t('Docs / SK: {{n}} source connected', { n: skSources.length })
: t('Docs / SK: none (skipped)'),
t('Cloud resources: test-tier only (auto-discovery)'),
]
const willNotDo = [
t('Production deploy without desktop client approval'),
t('Production database write or migration'),
t('Export long-lived credentials'),
t('Delete cloud resources outside the task scope'),
]
const highRisk = [
t(
'Production deploy, production secrets and destructive ops require client approval'
),
t('Approval issues short-lived, scope-limited credentials only'),
]
const handleLaunch = () => {
setLaunching(true)
// Real /api/agnet/deployments POST is wired separately when the task
// object backend lands. For now the summary card matches the docs spec
// visually; clicking captures intent + hands off to the desktop client.
setTimeout(() => {
toast.success(
t('Agnet launch staged. Continue the task in the desktop client.')
)
setLaunching(false)
onClose()
}, 400)
}
return (
<div className='bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm'>
<div className='bg-card max-h-[90vh] w-[min(720px,94vw)] overflow-auto rounded-2xl border border-[color-mix(in_oklch,var(--primary)_24%,var(--border))] p-5 shadow-2xl sm:p-6'>
<header className='flex items-start justify-between gap-3 border-b border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pb-3'>
<div>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.16em] uppercase'>
{t('Recommendation summary')}
</p>
<h3 className='mt-1 text-lg font-semibold'>
{t('Confirm scope, risk and budget before launching Agnet')}
</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Parameters are generated by Heicode. You only confirm the boundaries.'
)}
</p>
</div>
<Button type='button' variant='ghost' size='sm' onClick={onClose}>
{t('Close')}
</Button>
</header>
<div className='mt-4 grid gap-3 md:grid-cols-2'>
<RecBlock
title={t('Will do this run')}
tone='primary'
items={willDo}
/>
<RecBlock
title={t('Allowed to use')}
tone='primary'
items={allowedUse}
/>
<RecBlock title={t('Will NOT do')} tone='danger' items={willNotDo} />
<RecBlock title={t('High-risk rules')} tone='warn' items={highRisk} />
</div>
<div className='bg-background/40 mt-4 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] p-4'>
<p className='text-muted-foreground text-[11px] font-semibold tracking-[0.14em] uppercase'>
{t('Estimated consumption')}
</p>
<p className='text-foreground mt-1 text-sm'>
{t(
'Model budget will be capped to your default. Detailed usage shows up in Models & balance after the run.'
)}
</p>
</div>
<footer className='mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] pt-4'>
<p className='text-muted-foreground text-[11px]'>
{t('Parameters auto-generated by Heicode')}
</p>
<div className='flex items-center gap-2'>
<Button type='button' variant='outline' size='sm' onClick={onClose}>
{t('Back')}
</Button>
<Button
type='button'
size='sm'
disabled={launching}
onClick={handleLaunch}
className='gap-1 rounded-xl text-white'
style={{
backgroundImage: 'var(--gradient-brand-btn)',
border: '1px solid rgba(255,255,255,0.16)',
}}
>
<Rocket className='h-3.5 w-3.5' />
{launching ? t('Launching…') : t('Launch Agnet')}
</Button>
</div>
</footer>
</div>
</div>
)
}
function RecBlock({
title,
tone,
items,
}: {
title: string
tone: 'primary' | 'warn' | 'danger'
items: string[]
}) {
const toneCls: Record<typeof tone, { dot: string; border: string }> = {
primary: {
dot: 'bg-primary',
border: 'rgba(123,107,227,0.20)',
},
warn: {
dot: 'bg-amber-400',
border: 'rgba(245,158,11,0.30)',
},
danger: {
dot: 'bg-rose-400',
border: 'rgba(225,29,72,0.28)',
},
}
return (
<div
className='bg-background/40 rounded-xl border p-3'
style={{ borderColor: toneCls[tone].border }}
>
<p className='text-foreground text-xs font-semibold'>{title}</p>
<ul className='text-muted-foreground mt-2 space-y-1.5 text-xs'>
{items.map((line, i) => (
<li key={i} className='flex items-start gap-2'>
<span
className={cn(
'mt-1.5 inline-block h-1 w-1 shrink-0 rounded-full',
toneCls[tone].dot
)}
/>
<span>{line}</span>
</li>
))}
</ul>
</div>
)
}
// =============================================================================
// Templates / Agents (kept for backward compatibility — invoked by side routes)
// =============================================================================
export function AgnetTemplatesPage() {
const { t } = useTranslation()
const templates = [
{
id: 'agile_min',
name: t('Agile Minimal'),
desc: t('Fast loop team with short checkpoints.'),
},
{
id: 'waterfall_min',
name: t('Waterfall Minimal'),
desc: t('Phase-based team with strict gates.'),
},
]
return (
<PageSurface
title={t('Templates')}
subtitle={t('Starter orchestration shapes for resource-scoped runs.')}
>
<div className='grid gap-3 md:grid-cols-2'>
{templates.map((tpl) => (
<article
key={tpl.id}
className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-4'
>
<p className='text-sm font-semibold'>{tpl.name}</p>
<p className='text-muted-foreground mt-1 text-sm'>{tpl.desc}</p>
<p className='text-primary mt-3 font-mono text-[11px] tracking-[0.12em] uppercase'>
template_id: {tpl.id}
</p>
</article>
))}
</div>
</PageSurface>
)
}
function runtimeSummary(rt: AgnetRuntimeExecution | undefined): boolean {
if (!rt) return false
return Boolean(
(rt.profile_id && rt.profile_id.trim() !== '') ||
(rt.cloud_principal_refs && rt.cloud_principal_refs.length > 0) ||
(rt.network_policy_ref && rt.network_policy_ref.trim() !== '')
)
}
function policySummary(p: AgnetSKAccessPolicy | undefined): boolean {
if (!p) return false
return Boolean(
(p.policy_ref && p.policy_ref.trim() !== '') ||
(p.deny_skill_ids && p.deny_skill_ids.length > 0) ||
p.inherit_deployment_defaults
)
}
export function AgnetAgentsPage() {
const { t } = useTranslation()
const { data = [] } = useQuery({
queryKey: ['agnet', 'deployments'],
queryFn: listAgnetDeployments,
})
const rows = useMemo(
() =>
data.flatMap((dep: AgnetDeployment) =>
(dep.orchestration_plan?.agents || []).map((agent, idx) => ({
dep: dep.deployment_id,
id: `${dep.deployment_id}-${idx}`,
role: agent.role_template || '-',
runtimeModel: agent.default_model_id || '-',
goal: agent.goal || '-',
runtime: agent.runtime_execution,
skPolicy: agent.sk_access_policy,
}))
),
[data]
)
return (
<PageSurface
title={t('Agents')}
subtitle={t('Agent declarations parsed from each Agnet deployment plan.')}
>
{rows.length === 0 ? (
<EmptySurface
title={t('No agent definitions found in deployment plans.')}
/>
) : (
<ul className='space-y-2'>
{rows.map((row) => (
<li
key={row.id}
className='rounded-xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_55%,transparent)] p-3'
>
<p className='text-sm font-medium'>{row.role}</p>
<p className='text-muted-foreground mt-1 font-mono text-[11px] tracking-[0.12em] uppercase'>
{row.dep} · {t('Agnet runtime model')}: {row.runtimeModel}
</p>
<p className='mt-2 text-sm'>{row.goal}</p>
{runtimeSummary(row.runtime) && (
<div className='text-muted-foreground mt-3 rounded-lg border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_40%,transparent)] p-2 text-xs leading-relaxed'>
<p className='text-foreground font-semibold'>
{t('Runtime binding')}
</p>
{row.runtime?.profile_id ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Execution profile')}:{' '}
</span>
<span className='text-foreground font-mono'>
{row.runtime.profile_id}
</span>
</p>
) : null}
{(row.runtime?.cloud_principal_refs?.length ?? 0) > 0 ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Cloud principals')}:{' '}
</span>
<span className='text-foreground font-mono'>
{row.runtime?.cloud_principal_refs?.join(', ')}
</span>
</p>
) : null}
{row.runtime?.network_policy_ref ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Network policy')}:{' '}
</span>
<span className='text-foreground font-mono'>
{row.runtime.network_policy_ref}
</span>
</p>
) : null}
</div>
)}
{policySummary(row.skPolicy) && (
<div className='text-muted-foreground mt-3 rounded-lg border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] bg-[color-mix(in_oklch,var(--card)_40%,transparent)] p-2 text-xs leading-relaxed'>
<p className='text-foreground font-semibold'>
{t('SK access policy')}
</p>
{row.skPolicy?.policy_ref ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Policy ref')}:{' '}
</span>
<span className='text-foreground font-mono'>
{row.skPolicy.policy_ref}
</span>
</p>
) : null}
{(row.skPolicy?.deny_skill_ids?.length ?? 0) > 0 ? (
<p className='mt-1'>
<span className='text-muted-foreground'>
{t('Denied skills')}:{' '}
</span>
<span className='text-foreground font-mono'>
{row.skPolicy?.deny_skill_ids?.join(', ')}
</span>
</p>
) : null}
{row.skPolicy?.inherit_deployment_defaults ? (
<p className='text-muted-foreground mt-1'>
{t('Inherits deployment defaults')}
</p>
) : null}
</div>
)}
</li>
))}
</ul>
)}
</PageSurface>
)
}