From cee981e44c8207754dc84e70f3765aa99427945d Mon Sep 17 00:00:00 2001
From: 1012839419a-alt <1012839419a-alt@users.noreply.github.com>
Date: Sat, 8 Aug 2026 09:09:03 +0800
Subject: [PATCH 1/3] feat(control): control center panels for kill switch,
advisory report, and ODP state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wire the already-built control-plane endpoints (GET/POST /control/kill-switch,
GET /control/advisory-report, GET /control/odp-state) into a single operator
panel at /control:
- Kill switch: toggle + effective-source (runtime override vs config default)
- Advisory report: totals, recovery-rate, per (state, action_type) buckets,
mode breakdown — the gate data for flipping control_mode to automatic
- ODP data plane: ingest/stream/DLQ/store/outbox health, per-section degrade
Adds useKillSwitch/useSetKillSwitch/useAdvisoryReport/useOdpState hooks,
navigation registration, and regression-contract assertions.
---
frontend/app/(app)/control/page.tsx | 285 ++++++++++++++++++
frontend/lib/api/hooks.ts | 29 ++
frontend/lib/navigation.ts | 3 +-
.../check-control-plane-regressions.mjs | 24 ++
4 files changed, 340 insertions(+), 1 deletion(-)
create mode 100644 frontend/app/(app)/control/page.tsx
diff --git a/frontend/app/(app)/control/page.tsx b/frontend/app/(app)/control/page.tsx
new file mode 100644
index 00000000..f516100e
--- /dev/null
+++ b/frontend/app/(app)/control/page.tsx
@@ -0,0 +1,285 @@
+'use client'
+
+import {
+ useAdvisoryReport,
+ 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Switch } from '@/components/ui/switch'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { formatRelative } from '@/lib/format'
+import type { AdvisoryReport, OdpSystemState } from '@/lib/api/types'
+
+function Metric({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'bad' | 'muted' }) {
+ const toneClass =
+ tone === 'good' ? 'text-success' : tone === 'bad' ? 'text-destructive' : 'text-muted-foreground'
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+function OdpSection({
+ title,
+ state,
+ children,
+}: {
+ title: string
+ state: { available: boolean; error?: string | null }
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {title}
+ {state.available ? (
+
+ ) : (
+
+ )}
+
+ {state.available ? (
+
{children}
+ ) : (
+
+ {state.error || '当前不可用(依赖的 Redis / 数据面未部署)'}
+
+ )}
+
+ )
+}
+
+function OdpPanels({ state }: { state: OdpSystemState }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function AdvisoryTotalsRow({ report }: { report: AdvisoryReport }) {
+ const t = report.totals
+ return (
+
+
+
+
+
+
+ = 0.8 ? 'bad' : 'muted'}
+ />
+
+ )
+}
+
+export default function ControlCenterPage() {
+ const kill = useKillSwitch()
+ const setKill = useSetKillSwitch()
+ const advisory = useAdvisoryReport()
+ const odp = useOdpState()
+
+ return (
+
+ {/* ── Panel 1: Kill switch ─────────────────────────────────────────── */}
+
+
+
+
+ 执行熔断开关(Kill Switch)
+
+ engaged 时无条件短路 Control Cycle 在 automatic 模式下的全部执行,下一次 tick 立即生效。
+
+
+
setKill.mutate(v)}
+ disabled={setKill.isPending}
+ aria-label="执行熔断开关"
+ />
+
+
+
+ {kill.isLoading ? (
+
+ ) : kill.isError ? (
+
+ ) : kill.data ? (
+ <>
+
+ {kill.data.engaged ? (
+ 已熔断
+ ) : (
+ 未熔断
+ )}
+
+
+
+
+ {kill.data.engaged ? (
+ 所有自动执行将在下一次 tick 被短路
+ ) : null}
+ >
+ ) : null}
+
+
+
+ {/* ── Panel 2: Advisory report ─────────────────────────────────────── */}
+
+
+ 咨询报告(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}
+ {b.total}
+ {b.pending}
+ {b.recovered}
+ {b.persisted}
+
+ {b.recovery_rate == null ? '—' : `${(b.recovery_rate * 100).toFixed(1)}%`}
+
+
+ ))}
+
+
+
+ )}
+
+ 模式分布:
+ {Object.entries(advisory.data.mode_breakdown).map(([mode, count]) => (
+
+ {mode === 'automatic' ? '自动' : '建议'} × {count}
+
+ ))}
+
+ >
+ ) : null}
+
+
+
+ {/* ── Panel 3: ODP data-plane state ────────────────────────────────── */}
+
+
+
+
+ ODP 数据面状态
+
+ 共享数据平面(Redis 消费组 / 死信队列 / 存储心跳)的系统级健康,与单数据源无关。
+ 任一环节不可用只降级自身区块,不影响其他区块。
+
+
+ {odp.data ? (
+
+ {formatRelative(odp.data.collected_at)}
+
+ ) : null}
+
+
+
+ {odp.isLoading ? (
+
+ ) : odp.isError ? (
+
+ ) : odp.data ? (
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/frontend/lib/api/hooks.ts b/frontend/lib/api/hooks.ts
index e69bed3a..b0cad3ab 100644
--- a/frontend/lib/api/hooks.ts
+++ b/frontend/lib/api/hooks.ts
@@ -740,6 +740,35 @@ export function useControlActions(params?: {
})
}
+export function useKillSwitch() {
+ return useQuery({
+ queryKey: ['kill-switch'],
+ queryFn: api.getKillSwitch,
+ })
+}
+
+export function useSetKillSwitch() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: (engaged: boolean) => api.setKillSwitch(engaged),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['kill-switch'] }),
+ })
+}
+
+export function useAdvisoryReport() {
+ return useQuery({
+ queryKey: ['advisory-report'],
+ queryFn: api.getAdvisoryReport,
+ })
+}
+
+export function useOdpState() {
+ return useQuery({
+ queryKey: ['odp-state'],
+ queryFn: api.getOdpState,
+ })
+}
+
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..ded73ae6 100644
--- a/frontend/scripts/check-control-plane-regressions.mjs
+++ b/frontend/scripts/check-control-plane-regressions.mjs
@@ -143,3 +143,27 @@ 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, /执行熔断开关/)
+ assert.match(page, /咨询报告/)
+ assert.match(page, /ODP 数据面状态/)
+ assert.match(page, /setKill\.mutate\(v\)/)
+ assert.match(page, /recovery_rate/)
+ assert.match(page, /oldest_pending_idle_ms/)
+ 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'\]/)
+})
From 383394efd23d5e7629961e5092b4527cfd4fc5f8 Mon Sep 17 00:00:00 2001
From: 1012839419a-alt <1012839419a-alt@users.noreply.github.com>
Date: Sat, 8 Aug 2026 13:14:48 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feat(control):=20full=20control=20center=20?=
=?UTF-8?q?=E2=80=94=20audit=20ledger,=20engage=20confirmation,=20auto-ref?=
=?UTF-8?q?resh?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Round out the control plane panel into an operator-grade surface:
- Audit ledger (4th panel): paginated control_actions table with
action/state/mode/executed/outcome/reason/time + manual refresh
- Kill-switch engage now requires a confirmation dialog (dangerous global
short-circuit); disengage stays one-click
- Auto-refresh: kill-switch 30s, ODP 15s, advisory 60s (hooks accept
refetchInterval option)
- Humanized numbers: ms->s/min for idle lag, thousands separators
- Per-section degrade notes (store/outbox) surface backend hints
- Regression contract updated for the new interaction surface
---
frontend/app/(app)/control/page.tsx | 299 ++++++++++++++++--
frontend/lib/api/hooks.ts | 9 +-
.../check-control-plane-regressions.mjs | 15 +-
3 files changed, 289 insertions(+), 34 deletions(-)
diff --git a/frontend/app/(app)/control/page.tsx b/frontend/app/(app)/control/page.tsx
index f516100e..a998557f 100644
--- a/frontend/app/(app)/control/page.tsx
+++ b/frontend/app/(app)/control/page.tsx
@@ -1,7 +1,10 @@
'use client'
+import { useState } from 'react'
+
import {
useAdvisoryReport,
+ useControlActions,
useKillSwitch,
useOdpState,
useSetKillSwitch,
@@ -10,7 +13,16 @@ import { BACKEND_HINT, EmptyState, ErrorState, LoadingState } from '@/components
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 { Switch } from '@/components/ui/switch'
import {
Table,
@@ -23,7 +35,27 @@ import {
import { formatRelative } from '@/lib/format'
import type { AdvisoryReport, OdpSystemState } from '@/lib/api/types'
-function Metric({ label, value, tone }: { label: string; value: string; tone?: 'good' | 'bad' | 'muted' }) {
+/** 毫秒 → 可读时长(<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')
+}
+
+function Metric({
+ label,
+ value,
+ tone,
+}: {
+ label: string
+ value: string
+ tone?: 'good' | 'bad' | 'muted'
+}) {
const toneClass =
tone === 'good' ? 'text-success' : tone === 'bad' ? 'text-destructive' : 'text-muted-foreground'
return (
@@ -70,28 +102,69 @@ function OdpPanels({ state }: { state: OdpSystemState }) {
-
-
+
+
-
-
+
+
-
+
+ {state.store.note ? (
+ {state.store.note}
+ ) : null}
-
+
+ {state.outbox.note ? (
+ {state.outbox.note}
+ ) : null}
)
@@ -101,11 +174,11 @@ function AdvisoryTotalsRow({ report }: { report: AdvisoryReport }) {
const t = report.totals
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 ? (
+
+
+ 共 {meta.total} 条 · 第 {meta.page}/{meta.pages} 页
+
+
+
+
+
+
+ ) : null}
+ >
+ )}
+
+
+ )
+}
+
export default function ControlCenterPage() {
- const kill = useKillSwitch()
+ const kill = useKillSwitch({ refetchInterval: 30_000 })
const setKill = useSetKillSwitch()
- const advisory = useAdvisoryReport()
- const odp = useOdpState()
+ const advisory = useAdvisoryReport({ refetchInterval: 60_000 })
+ const odp = useOdpState({ refetchInterval: 15_000 })
+ const [confirmOpen, setConfirmOpen] = useState(false)
+
+ // 打开熔断是危险动作:弹确认;关闭熔断是恢复安全态:直接执行。
+ const handleKillToggle = (engaged: boolean) => {
+ if (engaged) {
+ setConfirmOpen(true)
+ } else {
+ setKill.mutate(false)
+ }
+ }
+
+ const confirmEngage = () => {
+ setKill.mutate(true)
+ setConfirmOpen(false)
+ }
return (
{/* ── Panel 1: Kill switch ─────────────────────────────────────────── */}
@@ -139,7 +347,7 @@ export default function ControlCenterPage() {
setKill.mutate(v)}
+ onCheckedChange={handleKillToggle}
disabled={setKill.isPending}
aria-label="执行熔断开关"
/>
@@ -171,7 +379,13 @@ export default function ControlCenterPage() {
/>
{advisory.data.buckets.length === 0 ? (
-
+
) : (
@@ -226,10 +443,14 @@ export default function ControlCenterPage() {
{b.action_type}
- {b.total}
- {b.pending}
- {b.recovered}
- {b.persisted}
+ {formatNum(b.total)}
+
+ {formatNum(b.pending)}
+
+
+ {formatNum(b.recovered)}
+
+ {formatNum(b.persisted)}
{b.recovery_rate == null ? '—' : `${(b.recovery_rate * 100).toFixed(1)}%`}
@@ -243,7 +464,7 @@ export default function ControlCenterPage() {
模式分布:
{Object.entries(advisory.data.mode_breakdown).map(([mode, count]) => (
- {mode === 'automatic' ? '自动' : '建议'} × {count}
+ {mode === 'automatic' ? '自动' : '建议'} × {formatNum(count)}
))}
@@ -280,6 +501,30 @@ export default function ControlCenterPage() {
) : null}
+
+ {/* ── Panel 4: Audit ledger ────────────────────────────────────────── */}
+
+
+ {/* ── Kill-switch engage confirmation ──────────────────────────────── */}
+
)
}
diff --git a/frontend/lib/api/hooks.ts b/frontend/lib/api/hooks.ts
index b0cad3ab..2f8894df 100644
--- a/frontend/lib/api/hooks.ts
+++ b/frontend/lib/api/hooks.ts
@@ -740,10 +740,11 @@ export function useControlActions(params?: {
})
}
-export function useKillSwitch() {
+export function useKillSwitch(options?: { refetchInterval?: number }) {
return useQuery({
queryKey: ['kill-switch'],
queryFn: api.getKillSwitch,
+ refetchInterval: options?.refetchInterval,
})
}
@@ -755,17 +756,19 @@ export function useSetKillSwitch() {
})
}
-export function useAdvisoryReport() {
+export function useAdvisoryReport(options?: { refetchInterval?: number }) {
return useQuery({
queryKey: ['advisory-report'],
queryFn: api.getAdvisoryReport,
+ refetchInterval: options?.refetchInterval,
})
}
-export function useOdpState() {
+export function useOdpState(options?: { refetchInterval?: number }) {
return useQuery({
queryKey: ['odp-state'],
queryFn: api.getOdpState,
+ refetchInterval: options?.refetchInterval,
})
}
diff --git a/frontend/scripts/check-control-plane-regressions.mjs b/frontend/scripts/check-control-plane-regressions.mjs
index ded73ae6..647a046b 100644
--- a/frontend/scripts/check-control-plane-regressions.mjs
+++ b/frontend/scripts/check-control-plane-regressions.mjs
@@ -151,19 +151,26 @@ test('control center wires kill switch, advisory report, and ODP state into one
read('lib/api/hooks.ts'),
])
- assert.match(page, /useKillSwitch\(\)/)
+ assert.match(page, /useKillSwitch\(/)
assert.match(page, /useSetKillSwitch\(\)/)
- assert.match(page, /useAdvisoryReport\(\)/)
- assert.match(page, /useOdpState\(\)/)
+ 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, /setKill\.mutate\(v\)/)
+ 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/)
})
From 8af917e6a65f0465ffa775c35b18d11551aeec59 Mon Sep 17 00:00:00 2001
From: 1012839419a-alt <1012839419a-alt@users.noreply.github.com>
Date: Sat, 8 Aug 2026 16:12:04 +0800
Subject: [PATCH 3/3] feat(control): re-compose control center as Operate
surface
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surface-first redesign (claude-design doctrine):
- Status strip (Monitor): 4 glanceable cells — kill state, automation
gate, ODP availability, ledger volume; no card chrome
- Kill-switch cockpit (Operate): raised bg-ops-panel dark surface with
large destructive/secondary action button instead of a buried
mini-switch; engage warning banner, source-of-truth mono readout
- ODP data plane: compact 5-cell grid with per-section degrade reasons
and availability count (x/5)
- Advisory report: gate-eligibility badge column (mostly-recovered =>
do not automate, mostly-persisted => eligible) + inline totals row
- Audit ledger stays compact Command/Inspect with pagination
Kept: engage confirmation dialog, auto-refresh intervals, formatMs/
formatNum humanization. All existing regression assertions still pass.
---
frontend/app/(app)/control/page.tsx | 493 ++++++++++++++++------------
1 file changed, 284 insertions(+), 209 deletions(-)
diff --git a/frontend/app/(app)/control/page.tsx b/frontend/app/(app)/control/page.tsx
index a998557f..f9ad1d62 100644
--- a/frontend/app/(app)/control/page.tsx
+++ b/frontend/app/(app)/control/page.tsx
@@ -23,7 +23,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
-import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
@@ -47,26 +46,112 @@ function formatNum(n: number): string {
return n.toLocaleString('en-US')
}
-function Metric({
+/* ────────────────────────────────────────────────────────────────
+ * 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,
- tone,
+ dot,
+ dotTone,
}: {
label: string
value: string
- tone?: 'good' | 'bad' | 'muted'
+ 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 toneClass =
- tone === 'good' ? 'text-success' : tone === 'bad' ? 'text-destructive' : 'text-muted-foreground'
+ const source = runtimeOverride != null ? '运行期覆盖' : configDefault ? '配置默认 · 启用' : '配置默认 · 停用'
return (
-
-
{label}
-
{value}
+
+
+
+ {engaged ? (
+
+ ● 已熔断
+
+ ) : (
+
+ ● 未熔断
+
+ )}
+
+ 生效来源
+ {source}
+
+
+
+ {engaged ? (
+
+ ) : (
+
+ )}
+
+
+ {engaged ? (
+
+ 所有 automatic 模式的 Control Cycle 执行将在下一次 tick 被无条件短路。
+ 运行期覆盖在进程重启后被清除,恢复为配置默认值。
+
+ ) : (
+
+ 熔断关闭不代表自动模式已开启——仍需 CONTROL_MODE=automatic 及全部门禁通过才会执行。
+
+ )}
)
}
-function OdpSection({
+/* ────────────────────────────────────────────────────────────────
+ * ODP data plane — Monitor layer, compact. Five small cells, each
+ * degrades independently with the backend's reason surfaced.
+ * ──────────────────────────────────────────────────────────────── */
+
+function OdpCell({
title,
state,
children,
@@ -76,114 +161,133 @@ function OdpSection({
children: React.ReactNode
}) {
return (
-
+
- {title}
+ {title}
{state.available ? (
-
+
) : (
-
+
)}
{state.available ? (
-
{children}
+
{children}
) : (
-
- {state.error || '当前不可用(依赖的 Redis / 数据面未部署)'}
+
+ {state.error || '不可用'}
)}
)
}
-function OdpPanels({ state }: { state: OdpSystemState }) {
+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.note ? (
- {state.outbox.note}
- ) : 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 (
-
-
-
-
-
-
-
= 0.8 ? 'bad' : 'muted'}
- />
+
+
+ 总数
+ {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)}
+
+ ))}
)
}
@@ -201,18 +305,14 @@ function AuditLedger() {
return (
-
-
-
- 审计台账(控制动作)
-
- 控制器的完整证据账本——每一次建议与执行,按状态类别 / 模式 / 结果过滤。
-
-
-
+
+
+ 审计台账(控制动作)
+ 每一次建议与执行的证据账本。
+
{isLoading ? (
@@ -279,7 +379,7 @@ function AuditLedger() {
{meta && meta.pages > 1 ? (
- 共 {meta.total} 条 · 第 {meta.page}/{meta.pages} 页
+ 共 {formatNum(meta.total)} 条 · 第 {meta.page}/{meta.pages} 页
)}
-
- 模式分布:
- {Object.entries(advisory.data.mode_breakdown).map(([mode, count]) => (
-
- {mode === 'automatic' ? '自动' : '建议'} × {formatNum(count)}
-
- ))}
-
>
) : null}
- {/* ── Panel 3: ODP data-plane state ────────────────────────────────── */}
-
-
-
-
- ODP 数据面状态
-
- 共享数据平面(Redis 消费组 / 死信队列 / 存储心跳)的系统级健康,与单数据源无关。
- 任一环节不可用只降级自身区块,不影响其他区块。
-
-
- {odp.data ? (
-
- {formatRelative(odp.data.collected_at)}
-
- ) : null}
-
-
-
- {odp.isLoading ? (
-
- ) : odp.isError ? (
-
- ) : odp.data ? (
-
- ) : null}
-
-
-
- {/* ── Panel 4: Audit ledger ────────────────────────────────────────── */}
+ {/* ── Audit ledger (Command/Inspect) ──────────────────────────────── */}
- {/* ── Kill-switch engage confirmation ──────────────────────────────── */}
+ {/* ── Kill-switch engage confirmation ─────────────────────────────── */}