feat(web): visualize auto execute slo diagnostics
This commit is contained in:
@@ -2191,6 +2191,45 @@
|
||||
"title": "整體合規率",
|
||||
"target": "目標 ≥ 95%"
|
||||
},
|
||||
"autoExecute": {
|
||||
"title": "自動修復紅燈診斷",
|
||||
"subtitle": "近 {window} 日 rolling window;顯示紅燈來源、封口狀態與回綠預估。",
|
||||
"threshold": "門檻 {target}",
|
||||
"windowSummary": "成功 {success}、失敗 {failed}、總樣本 {total}",
|
||||
"groupsTitle": "失敗群組與處理狀態",
|
||||
"groupCount": "{count} 次",
|
||||
"status": {
|
||||
"green": "已回綠",
|
||||
"sealed_waiting_window": "已封口,等待視窗",
|
||||
"needs_investigation": "仍需調查",
|
||||
"insufficient_diagnostics": "診斷不足",
|
||||
"diagnostics_unavailable": "診斷不可用",
|
||||
"unknown": "狀態待查"
|
||||
},
|
||||
"stat": {
|
||||
"successRate": "成功率",
|
||||
"successRateDetail": "{success}/{total} 目前樣本",
|
||||
"sealedGroups": "已封口",
|
||||
"sealedGroupsDetail": "未來同型失敗已加防線",
|
||||
"openGroups": "待查",
|
||||
"openGroupsDetail": "需要人工或 Agent 續查",
|
||||
"greenAt": "回綠時間",
|
||||
"greenAtDetail": "等待舊失敗自然滾出",
|
||||
"greenAtDetailWithNeed": "或新增 {count} 次真實成功"
|
||||
},
|
||||
"closure": {
|
||||
"sealed_by_mcp_grant": "MCP grant 已封口",
|
||||
"sealed_by_external_site_guard": "外部站台防呆已封口",
|
||||
"open_failure_source": "待查來源",
|
||||
"unknown": "狀態待查"
|
||||
},
|
||||
"nextAction": {
|
||||
"keep_monitoring": "持續監控,不需要人工介入。",
|
||||
"observe_rolling_window_no_manual_restart": "目前已知失敗來源已封口;等待 rolling window 回綠,不要重啟服務或改寫歷史資料。",
|
||||
"investigate_open_failure_groups": "仍有待查失敗群組;請反查 truth-chain、PlayBook 與 MCP 執行紀錄。",
|
||||
"refresh_truth_chain_and_execution_logs": "重新整理 truth-chain 與 execution logs,補齊診斷證據。"
|
||||
}
|
||||
},
|
||||
"coverage": {
|
||||
"title": "驗證覆蓋率",
|
||||
"subtitle": "近 {window} 自動修復執行與 verifier 寫回狀態",
|
||||
|
||||
@@ -2191,6 +2191,45 @@
|
||||
"title": "整體合規率",
|
||||
"target": "目標 ≥ 95%"
|
||||
},
|
||||
"autoExecute": {
|
||||
"title": "自動修復紅燈診斷",
|
||||
"subtitle": "近 {window} 日 rolling window;顯示紅燈來源、封口狀態與回綠預估。",
|
||||
"threshold": "門檻 {target}",
|
||||
"windowSummary": "成功 {success}、失敗 {failed}、總樣本 {total}",
|
||||
"groupsTitle": "失敗群組與處理狀態",
|
||||
"groupCount": "{count} 次",
|
||||
"status": {
|
||||
"green": "已回綠",
|
||||
"sealed_waiting_window": "已封口,等待視窗",
|
||||
"needs_investigation": "仍需調查",
|
||||
"insufficient_diagnostics": "診斷不足",
|
||||
"diagnostics_unavailable": "診斷不可用",
|
||||
"unknown": "狀態待查"
|
||||
},
|
||||
"stat": {
|
||||
"successRate": "成功率",
|
||||
"successRateDetail": "{success}/{total} 目前樣本",
|
||||
"sealedGroups": "已封口",
|
||||
"sealedGroupsDetail": "未來同型失敗已加防線",
|
||||
"openGroups": "待查",
|
||||
"openGroupsDetail": "需要人工或 Agent 續查",
|
||||
"greenAt": "回綠時間",
|
||||
"greenAtDetail": "等待舊失敗自然滾出",
|
||||
"greenAtDetailWithNeed": "或新增 {count} 次真實成功"
|
||||
},
|
||||
"closure": {
|
||||
"sealed_by_mcp_grant": "MCP grant 已封口",
|
||||
"sealed_by_external_site_guard": "外部站台防呆已封口",
|
||||
"open_failure_source": "待查來源",
|
||||
"unknown": "狀態待查"
|
||||
},
|
||||
"nextAction": {
|
||||
"keep_monitoring": "持續監控,不需要人工介入。",
|
||||
"observe_rolling_window_no_manual_restart": "目前已知失敗來源已封口;等待 rolling window 回綠,不要重啟服務或改寫歷史資料。",
|
||||
"investigate_open_failure_groups": "仍有待查失敗群組;請反查 truth-chain、PlayBook 與 MCP 執行紀錄。",
|
||||
"refresh_truth_chain_and_execution_logs": "重新整理 truth-chain 與 execution logs,補齊診斷證據。"
|
||||
}
|
||||
},
|
||||
"coverage": {
|
||||
"title": "驗證覆蓋率",
|
||||
"subtitle": "近 {window} 自動修復執行與 verifier 寫回狀態",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ShieldCheck, AlertTriangle, PlayCircle, SearchCheck } from 'lucide-react'
|
||||
import { ShieldCheck, AlertTriangle, PlayCircle, SearchCheck, CheckCircle2, Clock3, Wrench } from 'lucide-react'
|
||||
import { SloKpiCard, type SloMetric } from '@/components/governance/slo-kpi-card'
|
||||
import { SloViolationChart, type ViolationDataPoint } from '@/components/governance/slo-violation-chart'
|
||||
import { GlassCard } from '@/components/ui/glass-card'
|
||||
@@ -60,6 +60,42 @@ interface SloApiResponse {
|
||||
}
|
||||
overall_compliance?: number
|
||||
computed_at?: string
|
||||
diagnostics?: {
|
||||
auto_execute_success_rate?: AutoExecuteDiagnostics
|
||||
}
|
||||
}
|
||||
|
||||
interface AutoExecuteDiagnostics {
|
||||
schema_version?: string
|
||||
status?: string
|
||||
summary?: {
|
||||
total?: number
|
||||
success?: number
|
||||
failed?: number
|
||||
rate?: number | null
|
||||
threshold?: number
|
||||
window_days?: number
|
||||
min_samples?: number
|
||||
}
|
||||
top_failure_groups?: AutoExecuteFailureGroup[]
|
||||
sealed_failure_group_count?: number
|
||||
open_failure_group_count?: number
|
||||
immediate_successes_needed?: number
|
||||
projected_green_at?: string | null
|
||||
projection_reason?: string | null
|
||||
next_action?: string | null
|
||||
}
|
||||
|
||||
interface AutoExecuteFailureGroup {
|
||||
alertname?: string | null
|
||||
playbook_id?: string | null
|
||||
playbook_name?: string | null
|
||||
count?: number
|
||||
closure_status?: string | null
|
||||
closure_label?: string | null
|
||||
recommended_action?: string | null
|
||||
first_seen?: string | null
|
||||
last_seen?: string | null
|
||||
}
|
||||
|
||||
interface Adr100VerificationCoverage {
|
||||
@@ -218,6 +254,37 @@ function formatPercent(value?: number | null): string {
|
||||
return value == null ? '--' : `${(value * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function autoExecuteStatusKey(value?: string | null): string {
|
||||
if (value === 'green') return value
|
||||
if (value === 'sealed_waiting_window') return value
|
||||
if (value === 'needs_investigation') return value
|
||||
if (value === 'insufficient_diagnostics') return value
|
||||
if (value === 'diagnostics_unavailable') return value
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function autoExecuteNextActionKey(value?: string | null): string {
|
||||
if (value === 'keep_monitoring') return value
|
||||
if (value === 'observe_rolling_window_no_manual_restart') return value
|
||||
if (value === 'investigate_open_failure_groups') return value
|
||||
if (value === 'refresh_truth_chain_and_execution_logs') return value
|
||||
return 'refresh_truth_chain_and_execution_logs'
|
||||
}
|
||||
|
||||
function closureStatusKey(value?: string | null): string {
|
||||
if (value === 'sealed_by_mcp_grant') return value
|
||||
if (value === 'sealed_by_external_site_guard') return value
|
||||
if (value === 'open_failure_source') return value
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function diagnosticTone(status?: string | null, openGroups = 0): string {
|
||||
if (openGroups > 0 || status === 'needs_investigation') return '#FF3300'
|
||||
if (status === 'green') return '#22C55E'
|
||||
if (status === 'diagnostics_unavailable') return '#87867f'
|
||||
return '#F59E0B'
|
||||
}
|
||||
|
||||
function failureClassKey(value?: string | null): string {
|
||||
if (value === 'unsupported_action_scheme') return value
|
||||
if (value === 'verifier_missing_promql') return value
|
||||
@@ -292,6 +359,18 @@ function formatShortDateTime(value?: string | null): string {
|
||||
})
|
||||
}
|
||||
|
||||
function formatShortDate(value?: string | null): string {
|
||||
if (!value) return '--'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '--'
|
||||
return date.toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function historyRouteLabel(summary: RemediationHistoryWorkItemSummary): string {
|
||||
return compactLabel(
|
||||
[summary.latest_agent_id, summary.latest_tool_name]
|
||||
@@ -355,6 +434,205 @@ function buildMetrics(api: SloApiResponse): SloMetric[] {
|
||||
})
|
||||
}
|
||||
|
||||
function AutoExecuteDiagnosticsPanel({ diagnostics }: { diagnostics?: AutoExecuteDiagnostics }) {
|
||||
const t = useTranslations('governance.slo.autoExecute')
|
||||
const summary = diagnostics?.summary
|
||||
if (!summary) return null
|
||||
|
||||
const rate = summary.rate ?? null
|
||||
const threshold = summary.threshold ?? 0.85
|
||||
const total = summary.total ?? 0
|
||||
const success = summary.success ?? 0
|
||||
const failed = summary.failed ?? Math.max(total - success, 0)
|
||||
const sealedGroups = diagnostics?.sealed_failure_group_count ?? 0
|
||||
const openGroups = diagnostics?.open_failure_group_count ?? 0
|
||||
const tone = diagnosticTone(diagnostics?.status, openGroups)
|
||||
const progress = Math.max(0, Math.min(100, (rate ?? 0) * 100))
|
||||
const thresholdPosition = Math.max(0, Math.min(100, threshold * 100))
|
||||
const groups = diagnostics?.top_failure_groups ?? []
|
||||
const statusKey = autoExecuteStatusKey(diagnostics?.status)
|
||||
const nextActionKey = autoExecuteNextActionKey(diagnostics?.next_action)
|
||||
const projectedAt = formatShortDate(diagnostics?.projected_green_at)
|
||||
const rateLabel = rate == null ? '--' : `${(rate * 100).toFixed(1)}%`
|
||||
const thresholdLabel = `${(threshold * 100).toFixed(0)}%`
|
||||
|
||||
const statItems = [
|
||||
{ key: 'successRate', icon: CheckCircle2, value: rateLabel, detail: t('stat.successRateDetail', { success, total }) },
|
||||
{ key: 'sealedGroups', icon: Wrench, value: String(sealedGroups), detail: t('stat.sealedGroupsDetail') },
|
||||
{ key: 'openGroups', icon: AlertTriangle, value: String(openGroups), detail: t('stat.openGroupsDetail') },
|
||||
{
|
||||
key: 'greenAt',
|
||||
icon: Clock3,
|
||||
value: projectedAt,
|
||||
detail: diagnostics?.immediate_successes_needed
|
||||
? t('stat.greenAtDetailWithNeed', { count: diagnostics.immediate_successes_needed })
|
||||
: t('stat.greenAtDetail'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<GlassCard variant="subtle" padding="md">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<AlertTriangle size={15} style={{ color: tone, flexShrink: 0 }} />
|
||||
<div style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
|
||||
{t('title')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 5, fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45 }}>
|
||||
{t('subtitle', { window: summary.window_days ?? 7 })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
minHeight: 26,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
border: `0.5px solid ${tone}40`,
|
||||
background: `${tone}12`,
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 10,
|
||||
color: tone,
|
||||
}}>
|
||||
{t(`status.${statusKey}`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 1.1fr) minmax(260px, 1.8fr)', gap: 14 }} className="slo-auto-diagnostics-grid">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10, marginBottom: 8 }}>
|
||||
<div style={{ fontFamily: 'Syne, sans-serif', fontSize: 30, fontWeight: 700, color: tone, letterSpacing: 0, lineHeight: 1 }}>
|
||||
{rateLabel}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
|
||||
{t('threshold', { target: thresholdLabel })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
background: 'rgba(20,20,19,0.08)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ width: `${progress}%`, height: '100%', background: tone, borderRadius: 5 }} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: `${thresholdPosition}%`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 2,
|
||||
background: '#141413',
|
||||
opacity: 0.45,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ marginTop: 7, fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45 }}>
|
||||
{t('windowSummary', { success, failed, total })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8 }} className="slo-auto-stat-grid">
|
||||
{statItems.map(item => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.key} style={{
|
||||
minWidth: 0,
|
||||
padding: '8px 9px',
|
||||
borderRadius: 6,
|
||||
border: '0.5px solid rgba(20,20,19,0.08)',
|
||||
background: 'rgba(255,255,255,0.58)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
|
||||
<Icon size={13} style={{ color: item.key === 'openGroups' && openGroups > 0 ? '#FF3300' : tone, flexShrink: 0 }} />
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 9, color: '#87867f' }}>
|
||||
{t(`stat.${item.key}`)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'Syne, sans-serif', fontSize: 18, fontWeight: 700, color: '#141413', letterSpacing: 0 }}>
|
||||
{item.value}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, fontFamily: "'DM Mono', monospace", fontSize: 9, color: '#87867f', lineHeight: 1.35 }}>
|
||||
{item.detail}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontFamily: 'Syne, sans-serif', fontSize: 11, fontWeight: 700, color: '#141413' }}>
|
||||
{t('groupsTitle')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{groups.slice(0, 5).map(group => {
|
||||
const closureKey = closureStatusKey(group.closure_status)
|
||||
const closureTone = closureKey === 'open_failure_source' ? '#FF3300' : '#22C55E'
|
||||
return (
|
||||
<div key={`${group.alertname}-${group.playbook_id}-${closureKey}`} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(130px, 0.75fr) minmax(180px, 1fr) minmax(150px, 0.8fr) minmax(150px, 1fr)',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
padding: '7px 0',
|
||||
borderTop: '0.5px solid rgba(20,20,19,0.08)',
|
||||
}} className="slo-auto-group-row">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#141413', overflowWrap: 'anywhere' }}>
|
||||
{compactLabel(group.alertname)}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 9, color: '#87867f', marginTop: 2 }}>
|
||||
{t('groupCount', { count: group.count ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#141413', overflowWrap: 'anywhere' }}>
|
||||
{compactLabel(group.playbook_id)}
|
||||
</div>
|
||||
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 9, color: '#87867f', marginTop: 2, overflowWrap: 'anywhere' }}>
|
||||
{compactLabel(group.playbook_name)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: 'fit-content',
|
||||
minHeight: 24,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: '3px 7px',
|
||||
borderRadius: 6,
|
||||
border: `0.5px solid ${closureTone}40`,
|
||||
background: `${closureTone}12`,
|
||||
fontFamily: "'DM Mono', monospace",
|
||||
fontSize: 9,
|
||||
color: closureTone,
|
||||
}}>
|
||||
{t(`closure.${closureKey}`)}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, fontFamily: "'DM Mono', monospace", fontSize: 9, color: '#87867f', lineHeight: 1.4, overflowWrap: 'anywhere' }}>
|
||||
{group.recommended_action || group.closure_label || '--'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#4b5563', lineHeight: 1.45 }}>
|
||||
<SearchCheck size={13} style={{ color: tone, flexShrink: 0 }} />
|
||||
<span>{t(`nextAction.${nextActionKey}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
function VerificationCoveragePanel({ coverage }: { coverage?: Adr100VerificationCoverage }) {
|
||||
const t = useTranslations('governance.slo.coverage')
|
||||
const [actionState, setActionState] = useState<Record<string, RemediationActionState>>({})
|
||||
@@ -721,6 +999,7 @@ export function SloTab() {
|
||||
const metrics = sloData ? buildMetrics(sloData) : []
|
||||
const compliance = sloData?.adr100?.overall_compliance ?? sloData?.overall_compliance ?? null
|
||||
const verificationCoverage = sloData?.adr100?.verification_coverage
|
||||
const autoExecuteDiagnostics = sloData?.diagnostics?.auto_execute_success_rate
|
||||
|
||||
const chartData: ViolationDataPoint[] = summaryData?.data ?? []
|
||||
const eventTypes: string[] = summaryData?.event_types ?? []
|
||||
@@ -783,6 +1062,8 @@ export function SloTab() {
|
||||
}
|
||||
</div>
|
||||
|
||||
{!sloLoading && <AutoExecuteDiagnosticsPanel diagnostics={autoExecuteDiagnostics} />}
|
||||
|
||||
{!sloLoading && <VerificationCoveragePanel coverage={verificationCoverage} />}
|
||||
|
||||
{/* Violation timeline chart */}
|
||||
@@ -799,6 +1080,9 @@ export function SloTab() {
|
||||
@media (max-width: 640px) {
|
||||
.slo-kpi-grid > * { flex: 0 0 100%; min-width: 0; }
|
||||
.slo-coverage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.slo-auto-diagnostics-grid { grid-template-columns: 1fr !important; }
|
||||
.slo-auto-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.slo-auto-group-row { grid-template-columns: 1fr !important; }
|
||||
.slo-finding-row { grid-template-columns: 1fr !important; }
|
||||
.slo-remediation-row { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user