2596 lines
95 KiB
TypeScript
Vendored
2596 lines
95 KiB
TypeScript
Vendored
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>
|
||
)
|
||
}
|