diff --git a/frontend/app/(app)/control/page.tsx b/frontend/app/(app)/control/page.tsx new file mode 100644 index 00000000..f9ad1d62 --- /dev/null +++ b/frontend/app/(app)/control/page.tsx @@ -0,0 +1,605 @@ +'use client' + +import { useState } from 'react' + +import { + useAdvisoryReport, + useControlActions, + useKillSwitch, + useOdpState, + useSetKillSwitch, +} from '@/lib/api/hooks' +import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components/shell/data-states' +import { PageContainer } from '@/components/shell/page-container' +import { StatusBadge } from '@/components/shell/status-badge' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { formatRelative } from '@/lib/format' +import type { AdvisoryReport, OdpSystemState } from '@/lib/api/types' + +/** 毫秒 → 可读时长(<1s 显示 ms,否则 s/min)。 */ +function formatMs(ms: number): string { + if (ms < 1000) return `${ms}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${Math.round(ms / 60_000)}min` +} + +/** 大数千分位。 */ +function formatNum(n: number): string { + return n.toLocaleString('en-US') +} + +/* ──────────────────────────────────────────────────────────────── + * Status strip — the glanceable Monitor layer. + * Four compact cells: kill state, automation gate, ODP availability, + * ledger volume. No card chrome, just label + value + dot. + * ──────────────────────────────────────────────────────────────── */ + +function StripCell({ + label, + value, + dot, + dotTone, +}: { + label: string + value: string + dot?: boolean + dotTone?: 'good' | 'warn' | 'bad' | 'muted' +}) { + const dotClass = + dotTone === 'good' + ? 'bg-success' + : dotTone === 'bad' + ? 'bg-destructive' + : dotTone === 'warn' + ? 'bg-warning' + : 'bg-muted-foreground/50' + return ( +
+ {dot ? : null} +
+ {label} + {value} +
+
+ ) +} + +/* ──────────────────────────────────────────────────────────────── + * Kill-switch — the Operate layer's primary action. Rendered as a + * raised ops-panel cockpit (dark surface, distinct from cards) with + * a large action button, not a buried mini-switch. + * ──────────────────────────────────────────────────────────────── */ + +function KillCockpit({ + engaged, + runtimeOverride, + configDefault, + isPending, + onToggle, +}: { + engaged: boolean + runtimeOverride: boolean | null + configDefault: boolean + isPending: boolean + onToggle: (engaged: boolean) => void +}) { + const source = runtimeOverride != null ? '运行期覆盖' : configDefault ? '配置默认 · 启用' : '配置默认 · 停用' + return ( +
+
+
+ {engaged ? ( + + ● 已熔断 + + ) : ( + + ● 未熔断 + + )} +
+ 生效来源 + {source} +
+
+
+ {engaged ? ( + + ) : ( + + )} +
+
+ {engaged ? ( +

+ 所有 automatic 模式的 Control Cycle 执行将在下一次 tick 被无条件短路。 + 运行期覆盖在进程重启后被清除,恢复为配置默认值。 +

+ ) : ( +

+ 熔断关闭不代表自动模式已开启——仍需 CONTROL_MODE=automatic 及全部门禁通过才会执行。 +

+ )} +
+ ) +} + +/* ──────────────────────────────────────────────────────────────── + * ODP data plane — Monitor layer, compact. Five small cells, each + * degrades independently with the backend's reason surfaced. + * ──────────────────────────────────────────────────────────────── */ + +function OdpCell({ + title, + state, + children, +}: { + title: string + state: { available: boolean; error?: string | null } + children: React.ReactNode +}) { + return ( +
+
+ {title} + {state.available ? ( + + ) : ( + + )} +
+ {state.available ? ( +
{children}
+ ) : ( +

+ {state.error || '不可用'} +

+ )} +
+ ) +} + +function OdpGrid({ state }: { state: OdpSystemState }) { + const availableCount = [ + state.ingest.available, + state.stream.available, + state.dlq.available, + state.store.available, + state.outbox.available, + ].filter(Boolean).length + return ( +
+
+ + + {state.ingest.healthy === true ? '健康' : state.ingest.healthy === false ? '异常' : '未知'} + + + + {state.stream.group || '—'} + + lag {state.stream.lag == null ? '—' : formatNum(state.stream.lag)} + + + pend {state.stream.pending == null ? '—' : formatNum(state.stream.pending)} + + + idle{' '} + {state.stream.oldest_pending_idle_ms == null + ? '—' + : formatMs(state.stream.oldest_pending_idle_ms)} + + + + {state.dlq.total == null ? '—' : formatNum(state.dlq.total)} + + 24h {state.dlq.last_24h == null ? '—' : formatNum(state.dlq.last_24h)} + + + + + {state.store.heartbeat_age_seconds == null + ? '—' + : `${state.store.heartbeat_age_seconds}s`} + + {state.store.note ? ( + + {state.store.note} + + ) : null} + + + + {state.outbox.unpublished == null ? '—' : formatNum(state.outbox.unpublished)} + + {state.outbox.note ? ( + + {state.outbox.note} + + ) : null} + +
+ + 可用区块 {availableCount}/5 · 采集于 {formatRelative(state.collected_at)} + +
+ ) +} + +/* ──────────────────────────────────────────────────────────────── + * Advisory report — the automation-gate data. Buckets carry a gate + * badge: mostly-recovered buckets must NOT be automated, mostly- + * persisted ones qualify. + * ──────────────────────────────────────────────────────────────── */ + +function gateEligible(bucket: AdvisoryReport['buckets'][number]): boolean { + if (bucket.recovery_rate == null) return false + return bucket.recovery_rate < 0.8 && bucket.persisted > 0 +} + +function AdvisoryTotalsRow({ report }: { report: AdvisoryReport }) { + const t = report.totals + return ( +
+ + 总数 + {formatNum(t.total)} + + + 待评估 + {formatNum(t.pending)} + + + 已恢复 + {formatNum(t.recovered)} + + + 已固化 + {formatNum(t.persisted)} + + + 恢复率 + {t.recovery_rate == null ? '—' : `${(t.recovery_rate * 100).toFixed(1)}%`} + + {Object.entries(report.mode_breakdown).map(([mode, count]) => ( + + {mode === 'automatic' ? '自动' : '建议'} × {formatNum(count)} + + ))} +
+ ) +} + +const PAGE_SIZE = 10 + +function AuditLedger() { + const [page, setPage] = useState(1) + const { data, isLoading, isError, error, refetch, isFetching } = useControlActions({ + page, + limit: PAGE_SIZE, + }) + const actions = data?.data ?? [] + const meta = data?.meta + + return ( + + +
+ 审计台账(控制动作) + 每一次建议与执行的证据账本。 +
+ +
+ + {isLoading ? ( + + ) : isError ? ( + + ) : actions.length === 0 ? ( + + ) : ( + <> + + + + + 动作类型 + 状态类别 + 模式 + 执行 + 结果 + 原因 + 时间 + + + + {actions.map((a) => ( + + {a.action_type} + + + + + + {a.mode === 'automatic' ? '自动' : '建议'} + + + + {a.executed ? ( + 已执行 + ) : ( + 未执行 + )} + + + {a.outcome ? ( + + ) : ( + 待评估 + )} + + + {a.reason || '—'} + + + {formatRelative(a.created_at)} + + + ))} + +
+
+ {meta && meta.pages > 1 ? ( +
+ + 共 {formatNum(meta.total)} 条 · 第 {meta.page}/{meta.pages} 页 + +
+ + +
+
+ ) : null} + + )} +
+
+ ) +} + +export default function ControlCenterPage() { + const kill = useKillSwitch({ refetchInterval: 30_000 }) + const setKill = useSetKillSwitch() + const advisory = useAdvisoryReport({ refetchInterval: 60_000 }) + const odp = useOdpState({ refetchInterval: 15_000 }) + const ledger = useControlActions({ page: 1, limit: PAGE_SIZE }) + const [confirmOpen, setConfirmOpen] = useState(false) + + // 打开熔断是危险动作:弹确认;关闭熔断是恢复安全态:直接执行。 + const handleKillToggle = (engaged: boolean) => { + if (engaged) { + setConfirmOpen(true) + } else { + setKill.mutate(false) + } + } + + const confirmEngage = () => { + setKill.mutate(true) + setConfirmOpen(false) + } + + const odpAvailable = odp.data + ? [odp.data.ingest.available, odp.data.stream.available, odp.data.dlq.available, odp.data.store.available, odp.data.outbox.available].filter(Boolean).length + : null + const qualifiedBuckets = advisory.data?.buckets.filter(gateEligible).length ?? null + const ledgerTotal = ledger.data?.meta?.total ?? null + + return ( + + {/* ── Status strip (Monitor) ──────────────────────────────────────── */} +
+ + 0 ? 'warn' : 'muted'} + /> + 0 ? 'warn' : 'bad'} + /> + +
+ + {/* ── Kill switch cockpit (Operate) ───────────────────────────────── */} + {kill.isError ? ( + + ) : kill.isLoading ? ( + + ) : kill.data ? ( + + ) : null} + + {/* ── ODP data plane (Monitor) ───────────────────────────────────── */} + + + ODP 数据面状态 + + 共享数据平面(Redis 消费组 / 死信队列 / 存储心跳)的系统级健康,与单数据源无关。任一环节不可用只降级自身区块。 + + + + {odp.isLoading ? ( + + ) : odp.isError ? ( + + ) : odp.data ? ( + + ) : null} + + + + {/* ── Advisory report (gate data) ─────────────────────────────────── */} + + + 咨询报告(Advisory Report) + + control_actions 证据台账的收敛/恢复统计。某 (state, action_type) 组合的建议大多「已恢复」说明过度建议,不应自动化;大多「已固化」才具备翻转 automatic 的门禁资格。读取时自动完成一次懒评估。 + + + + {advisory.isLoading ? ( + + ) : advisory.isError ? ( + + ) : advisory.data ? ( + <> + + {advisory.data.buckets.length === 0 ? ( + + ) : ( + + + + + 状态类别 + 动作类型 + 总数 + 待评估 + 已恢复 + 已固化 + 恢复率 + 门禁 + + + + {advisory.data.buckets.map((b) => ( + + + + + {b.action_type} + {formatNum(b.total)} + + {formatNum(b.pending)} + + + {formatNum(b.recovered)} + + {formatNum(b.persisted)} + + {b.recovery_rate == null ? '—' : `${(b.recovery_rate * 100).toFixed(1)}%`} + + + {gateEligible(b) ? ( + + 可自动化 + + ) : ( + 不宜自动化 + )} + + + ))} + +
+
+ )} + + ) : null} +
+
+ + {/* ── Audit ledger (Command/Inspect) ──────────────────────────────── */} + + + {/* ── Kill-switch engage confirmation ─────────────────────────────── */} + + + + 确认熔断全部自动执行? + + + 此操作会无条件短路 Control Cycle 在 automatic 模式下的全部执行 + + ,下一次 tick 立即生效。运行期覆盖在进程重启后会被清除,恢复为配置默认值。 + + + + + + + + +
+ ) +} diff --git a/frontend/lib/api/hooks.ts b/frontend/lib/api/hooks.ts index e69bed3a..2f8894df 100644 --- a/frontend/lib/api/hooks.ts +++ b/frontend/lib/api/hooks.ts @@ -740,6 +740,38 @@ export function useControlActions(params?: { }) } +export function useKillSwitch(options?: { refetchInterval?: number }) { + return useQuery({ + queryKey: ['kill-switch'], + queryFn: api.getKillSwitch, + refetchInterval: options?.refetchInterval, + }) +} + +export function useSetKillSwitch() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (engaged: boolean) => api.setKillSwitch(engaged), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['kill-switch'] }), + }) +} + +export function useAdvisoryReport(options?: { refetchInterval?: number }) { + return useQuery({ + queryKey: ['advisory-report'], + queryFn: api.getAdvisoryReport, + refetchInterval: options?.refetchInterval, + }) +} + +export function useOdpState(options?: { refetchInterval?: number }) { + return useQuery({ + queryKey: ['odp-state'], + queryFn: api.getOdpState, + refetchInterval: options?.refetchInterval, + }) +} + export function useInfiniteControlActions(params?: { source_id?: string mode?: string diff --git a/frontend/lib/navigation.ts b/frontend/lib/navigation.ts index 4db59d73..8ca913a5 100644 --- a/frontend/lib/navigation.ts +++ b/frontend/lib/navigation.ts @@ -71,7 +71,7 @@ export const NAV_GROUPS: NavGroup[] = [ href: '/providers', label: '模型与连接', icon: ShieldCheck, - match: ['/providers', '/control/actions'], + match: ['/providers', '/control/actions', '/control'], }, ], }, @@ -96,4 +96,5 @@ export const ROUTE_LABELS: Record = { '/nodes': '执行资源', '/workers': 'Worker', '/control/actions': '控制与审计', + '/control': '控制中心', } diff --git a/frontend/scripts/check-control-plane-regressions.mjs b/frontend/scripts/check-control-plane-regressions.mjs index 48fd309a..647a046b 100644 --- a/frontend/scripts/check-control-plane-regressions.mjs +++ b/frontend/scripts/check-control-plane-regressions.mjs @@ -143,3 +143,34 @@ test('OpenCLI is one provider with a full live website adapter directory', async assert.match(adapterClient, /params\.set\("refresh"/) assert.match(adapterClient, /signal: options\.signal/) }) + +test('control center wires kill switch, advisory report, and ODP state into one panel', async () => { + const [page, navigation, hooks] = await Promise.all([ + read('app/(app)/control/page.tsx'), + read('lib/navigation.ts'), + read('lib/api/hooks.ts'), + ]) + + assert.match(page, /useKillSwitch\(/) + assert.match(page, /useSetKillSwitch\(\)/) + assert.match(page, /useAdvisoryReport\(/) + assert.match(page, /useOdpState\(/) + assert.match(page, /useControlActions\(/) + assert.match(page, /执行熔断开关/) + assert.match(page, /咨询报告/) + assert.match(page, /ODP 数据面状态/) + assert.match(page, /审计台账/) + assert.match(page, /handleKillToggle/) + assert.match(page, /refetchInterval: 30_000/) + assert.match(page, /refetchInterval: 15_000/) + assert.match(page, /确认熔断全部自动执行/) + assert.match(page, /recovery_rate/) + assert.match(page, /oldest_pending_idle_ms/) + assert.match(page, /formatMs/) + assert.match(navigation, /'\/control'/) + assert.match(navigation, /控制中心/) + assert.match(hooks, /queryKey: \['kill-switch'\]/) + assert.match(hooks, /queryKey: \['advisory-report'\]/) + assert.match(hooks, /queryKey: \['odp-state'\]/) + assert.match(hooks, /refetchInterval: options\?\.refetchInterval/) +})