From e7482c402a01cfa2460f0609022c3948b9ad9650 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 30 Jun 2026 00:34:18 +0800 Subject: [PATCH] feat(web): surface reboot drill preflight --- apps/web/messages/en.json | 39 ++- apps/web/messages/zh-TW.json | 39 ++- apps/web/src/app/[locale]/delivery/page.tsx | 258 +++++++++++++++++++- apps/web/src/lib/api-client.ts | 135 ++++++++++ docs/LOGBOOK.md | 14 ++ 5 files changed, 472 insertions(+), 13 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index d3cd7701..a2fc38cb 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -106,7 +106,8 @@ "1": { "error": "Gitea private inventory 資料未回讀" }, "2": { "error": "Gitea / runner 資料未回讀" }, "3": { "error": "Runtime surface 資料未回讀" }, - "4": { "error": "Backup readiness 資料未回讀" } + "4": { "error": "Backup readiness 資料未回讀" }, + "5": { "error": "P0-006 reboot drill preflight 資料未回讀" } }, "metrics": { "loaded": "資料來源", @@ -123,9 +124,35 @@ "lanesDetail": "每張卡只回答完成度、阻擋數、下一步入口。", "next": "下一步焦點", "nextDetail": "只列需要處理的主線,不列文件清單。", + "drill": "P0-006 reboot drill preflight", + "drillDetail": "fresh all-host reboot window 仍是唯一 blocker;這裡只顯示預檢、驗證與硬邊界。", "boundary": "保留硬邊界", "boundaryDetail": "這些仍需明確授權,但不得阻擋低風險 coding / UI / test。" }, + "drill": { + "title": "10 分鐘恢復 SLO 預檢", + "detail": "目前 blocker:{blocker}。", + "unavailable": "P0-006 drill preflight 尚未回讀。", + "operations": "Operations", + "metrics": { + "preflight": "預檢狀態", + "target": "目標主機", + "readiness": "SLO readiness", + "execution": "本端點執行" + }, + "values": { + "ready": "ready", + "blocked": "blocked", + "enabled": "enabled", + "disabled": "disabled" + }, + "flags": { + "breakGlass": "break-glass required: {value}", + "hostReboot": "host reboot performed: {value}", + "runtimeWrite": "runtime write: {value}", + "secret": "secret read: {value}" + } + }, "lanes": { "release": { "title": "乾淨 release 工作流", @@ -147,6 +174,16 @@ "description": "確認 P0 dev/prod baseline source 已齊,讓後續 workflow template apply gate 有真實來源。", "metric": "source {present}/{required}" }, + "reboot_auto_recovery": { + "title": "P0-006 reboot recovery SLO", + "description": "服務、資料與備份已綠燈;剩下 fresh all-host reboot window 或另行批准 drill 才能證明 10 分鐘恢復。", + "metric": "hosts {hosts} · stale {stale} · Stock {freshness}" + }, + "credential_escrow": { + "title": "P0-005 credential escrow", + "description": "已收斂 non-secret evidence refs 與 reviewer acceptance readback;不寫 credential marker、不收 secret。", + "metric": "evidence {accepted}/{required}" + }, "gitea": { "title": "Gitea / CI-CD", "description": "確認 workflow、runner label、通知與 dev / prod 發版線是真實可跑。", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index b16e7d9f..f785a2da 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -106,7 +106,8 @@ "1": { "error": "Gitea private inventory 資料未回讀" }, "2": { "error": "Gitea / runner 資料未回讀" }, "3": { "error": "Runtime surface 資料未回讀" }, - "4": { "error": "Backup readiness 資料未回讀" } + "4": { "error": "Backup readiness 資料未回讀" }, + "5": { "error": "P0-006 reboot drill preflight 資料未回讀" } }, "metrics": { "loaded": "資料來源", @@ -123,9 +124,35 @@ "lanesDetail": "每張卡只回答完成度、阻擋數、下一步入口。", "next": "下一步焦點", "nextDetail": "只列需要處理的主線,不列文件清單。", + "drill": "P0-006 reboot drill preflight", + "drillDetail": "fresh all-host reboot window 仍是唯一 blocker;這裡只顯示預檢、驗證與硬邊界。", "boundary": "保留硬邊界", "boundaryDetail": "這些仍需明確授權,但不得阻擋低風險 coding / UI / test。" }, + "drill": { + "title": "10 分鐘恢復 SLO 預檢", + "detail": "目前 blocker:{blocker}。", + "unavailable": "P0-006 drill preflight 尚未回讀。", + "operations": "Operations", + "metrics": { + "preflight": "預檢狀態", + "target": "目標主機", + "readiness": "SLO readiness", + "execution": "本端點執行" + }, + "values": { + "ready": "ready", + "blocked": "blocked", + "enabled": "enabled", + "disabled": "disabled" + }, + "flags": { + "breakGlass": "break-glass required: {value}", + "hostReboot": "host reboot performed: {value}", + "runtimeWrite": "runtime write: {value}", + "secret": "secret read: {value}" + } + }, "lanes": { "release": { "title": "乾淨 release 工作流", @@ -147,6 +174,16 @@ "description": "確認 P0 dev/prod baseline source 已齊,讓後續 workflow template apply gate 有真實來源。", "metric": "source {present}/{required}" }, + "reboot_auto_recovery": { + "title": "P0-006 reboot recovery SLO", + "description": "服務、資料與備份已綠燈;剩下 fresh all-host reboot window 或另行批准 drill 才能證明 10 分鐘恢復。", + "metric": "hosts {hosts} · stale {stale} · Stock {freshness}" + }, + "credential_escrow": { + "title": "P0-005 credential escrow", + "description": "已收斂 non-secret evidence refs 與 reviewer acceptance readback;不寫 credential marker、不收 secret。", + "metric": "evidence {accepted}/{required}" + }, "gitea": { "title": "Gitea / CI-CD", "description": "確認 workflow、runner label、通知與 dev / prod 發版線是真實可跑。", diff --git a/apps/web/src/app/[locale]/delivery/page.tsx b/apps/web/src/app/[locale]/delivery/page.tsx index 31437f83..b7bac5e5 100644 --- a/apps/web/src/app/[locale]/delivery/page.tsx +++ b/apps/web/src/app/[locale]/delivery/page.tsx @@ -13,6 +13,7 @@ import { RefreshCw, Rocket, Server, + ShieldCheck, } from 'lucide-react' import { AppLayout } from '@/components/layout' import { GlassCard } from '@/components/ui/glass-card' @@ -22,11 +23,13 @@ import { type BackupDrReadinessMatrixSnapshot, type DeliveryClosureWorkbenchSnapshot, type GiteaPrivateInventoryP0ScorecardSnapshot, + type RebootAutoRecoveryDrillPreflightSnapshot, type GiteaWorkflowRunnerHealthSnapshot, type RuntimeSurfaceInventorySnapshot, } from '@/lib/api-client' type DeliveryTone = 'ok' | 'warn' | 'danger' | 'neutral' +type DeliveryTranslator = ReturnType interface DeliveryData { statusCleanup: AwoooIStatusCleanupDashboardSnapshot | null @@ -50,7 +53,7 @@ interface DeliveryLane { Icon: typeof Rocket } -const SOURCE_COUNT = 5 +const SOURCE_COUNT = 6 const EMPTY_DATA: DeliveryData = { statusCleanup: null, @@ -161,6 +164,94 @@ function MetricTile({ ) } +function DrillPreflightPanel({ + preflight, + locale, + t, +}: { + preflight: RebootAutoRecoveryDrillPreflightSnapshot | null + locale: string + t: DeliveryTranslator +}) { + if (!preflight) { + return ( +
+ +
+
+
+
+ ) + } + + const readiness = clampPercent(preflight.current_readback.readiness_percent) + const gateTone: DeliveryTone = preflight.execution_authorized_by_this_endpoint ? 'danger' : preflight.preflight_ready ? 'warn' : 'danger' + const boundaryTone: DeliveryTone = preflight.execution_authorized_by_this_endpoint ? 'danger' : 'ok' + const targetHosts = preflight.target_selector.required_host_aliases.join(' / ') + const activeBlocker = preflight.current_readback.active_blockers[0] ?? preflight.status + + return ( +
+
+
+

{t('sections.drill')}

+

{t('sections.drillDetail')}

+
+ +
+ +
+
+
+
+
+

{t('drill.title')}

+

{t('drill.detail', { blocker: activeBlocker })}

+
+
+ +
+
+ {t('drill.metrics.preflight')} + {preflight.preflight_ready ? t('drill.values.ready') : t('drill.values.blocked')} +
+
+ {t('drill.metrics.target')} + {targetHosts} +
+
+ {t('drill.metrics.readiness')} + {readiness}% +
+
+ {t('drill.metrics.execution')} + {preflight.execution_authorized_by_this_endpoint ? t('drill.values.enabled') : t('drill.values.disabled')} +
+
+ +
+ + + + +
+ +
+ {preflight.safe_next_step} + +
+
+
+
+ ) +} + function LaneCard({ lane, locale }: { lane: DeliveryLane; locale: string }) { return ( @@ -225,6 +316,7 @@ function LaneCard({ lane, locale }: { lane: DeliveryLane; locale: string }) { export default function DeliveryPage({ params }: { params: { locale: string } }) { const t = useTranslations('delivery') const [workbench, setWorkbench] = useState(null) + const [drillPreflight, setDrillPreflight] = useState(null) const [data, setData] = useState(EMPTY_DATA) const [errors, setErrors] = useState([]) const [loading, setLoading] = useState(true) @@ -235,13 +327,19 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) setLoading(true) setErrors([]) try { - const summary = await apiClient.getDeliveryClosureWorkbench() - if (cancelled) return - setWorkbench(summary) - setData(EMPTY_DATA) - setErrors([]) - setLoading(false) - return + const [summaryResult, drillResult] = await Promise.allSettled([ + apiClient.getDeliveryClosureWorkbench(), + apiClient.getRebootAutoRecoveryDrillPreflight(), + ]) + if (summaryResult.status === 'fulfilled') { + if (cancelled) return + setWorkbench(summaryResult.value) + setDrillPreflight(drillResult.status === 'fulfilled' ? drillResult.value : null) + setData(EMPTY_DATA) + setErrors(drillResult.status === 'fulfilled' ? [] : [t('sources.5.error')]) + setLoading(false) + return + } } catch { // Summary endpoint may not exist until the API release lands; keep the page useful with legacy reads. } @@ -252,6 +350,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) apiClient.getGiteaWorkflowRunnerHealth(), apiClient.getRuntimeSurfaceInventory(), apiClient.getBackupDrReadinessMatrix(), + apiClient.getRebootAutoRecoveryDrillPreflight(), ]) if (cancelled) return @@ -262,12 +361,14 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) runtime: results[3].status === 'fulfilled' ? results[3].value : null, backup: results[4].status === 'fulfilled' ? results[4].value : null, } + const nextDrillPreflight = results[5].status === 'fulfilled' ? results[5].value : null const nextErrors = results .map((result, index) => ({ result, index })) .filter(({ result }) => result.status === 'rejected') .map(({ index }) => t(`sources.${index}.error`)) setWorkbench(null) + setDrillPreflight(nextDrillPreflight) setData(nextData) setErrors(nextErrors) setLoading(false) @@ -282,11 +383,13 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) const lanes = useMemo(() => { if (workbench) { return workbench.lanes.map(lane => { - const iconMap = { + const iconMap: Record = { release: Rocket, production_deploy: Rocket, gitea_private_inventory: PackageCheck, cicd_baseline: PackageCheck, + reboot_auto_recovery: RefreshCw, + credential_escrow: ShieldCheck, gitea: PackageCheck, runtime: Server, backup: HardDrive, @@ -310,6 +413,17 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) present: lane.metric.present_required_source_count, required: lane.metric.required_source_count, }) + : lane.metric.kind === 'reboot_auto_recovery_slo' + ? t('lanes.reboot_auto_recovery.metric', { + hosts: lane.metric.observed_host_count, + stale: lane.metric.stale_host_count, + freshness: lane.metric.stockplatform_freshness_status, + }) + : lane.metric.kind === 'credential_escrow_evidence' + ? t('lanes.credential_escrow.metric', { + accepted: lane.metric.accepted_item_count, + required: lane.metric.required_item_count, + }) : lane.metric.kind === 'workflow_count' ? t('lanes.gitea.metric', { count: lane.metric.count }) : lane.metric.kind === 'surface_count' @@ -327,7 +441,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) nextAction: lane.next_action, href: lane.href, tone: lane.tone, - Icon: iconMap[lane.id], + Icon: iconMap[lane.id] ?? Rocket, } }) } @@ -417,7 +531,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) }, [data, t, workbench]) const sourceTotal = workbench?.summary.source_count ?? SOURCE_COUNT - const loadedCount = workbench?.summary.loaded_source_count ?? Object.values(data).filter(Boolean).length + const loadedCount = workbench?.summary.loaded_source_count ?? Object.values(data).filter(Boolean).length + (drillPreflight ? 1 : 0) const highRiskBlockers = workbench?.summary.high_risk_blocker_count ?? lanes.reduce((sum, lane) => sum + lane.blockerCount, 0) const averageCompletion = workbench?.summary.average_completion_percent ?? clampPercent(lanes.reduce((sum, lane) => sum + lane.percent, 0) / Math.max(lanes.length, 1)) const pageTone: DeliveryTone = highRiskBlockers > 0 ? 'danger' : loadedCount === sourceTotal ? 'ok' : 'warn' @@ -471,6 +585,8 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) + + {errors.length > 0 && (