feat(web): 顯示 Backup DR 治理證據
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
Code Review / ai-code-review (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-05 00:30:01 +08:00
parent 5dd274253d
commit b54477fdb6
8 changed files with 487 additions and 29 deletions

View File

@@ -9,7 +9,20 @@
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { AlertTriangle, Boxes, Database, Lock, PackageCheck, RefreshCw, Server, ShieldCheck } from 'lucide-react'
import {
AlertTriangle,
Archive,
BellOff,
BellRing,
Boxes,
Database,
HardDrive,
Lock,
PackageCheck,
RefreshCw,
Server,
ShieldCheck,
} from 'lucide-react'
import { useTranslations } from 'next-intl'
import { GlassCard } from '@/components/ui/glass-card'
import { StatusOrb } from '@/components/ui/status-orb'
@@ -17,6 +30,9 @@ import {
apiClient,
type AiAgentAutomationBacklogSnapshot,
type AiAgentAutomationInventorySnapshot,
type BackupDrReadinessMatrixSnapshot,
type BackupDrTargetInventorySnapshot,
type BackupNotificationPolicySnapshot,
} from '@/lib/api-client'
function formatDateTime(value: string): string {
@@ -75,6 +91,13 @@ function Chip({ value, muted = false }: { value: string; muted?: boolean }) {
)
}
function evidenceTone(value: string): 'ok' | 'warn' | 'danger' | 'neutral' {
if (value === 'ready' || value === 'verified' || value === 'active') return 'ok'
if (value === 'action_required' || value === 'approval_required' || value === 'needs_metric_binding') return 'warn'
if (value === 'blocked' || value === 'failed') return 'danger'
return 'neutral'
}
function MetricCard({
label,
value,
@@ -177,6 +200,9 @@ export function AutomationInventoryTab() {
const t = useTranslations('governance.automationInventory')
const [snapshot, setSnapshot] = useState<AiAgentAutomationInventorySnapshot | null>(null)
const [backlog, setBacklog] = useState<AiAgentAutomationBacklogSnapshot | null>(null)
const [backupTargets, setBackupTargets] = useState<BackupDrTargetInventorySnapshot | null>(null)
const [backupReadiness, setBackupReadiness] = useState<BackupDrReadinessMatrixSnapshot | null>(null)
const [backupPolicy, setBackupPolicy] = useState<BackupNotificationPolicySnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
@@ -185,10 +211,16 @@ export function AutomationInventoryTab() {
Promise.all([
apiClient.getAiAgentAutomationInventorySnapshot(),
apiClient.getAiAgentAutomationBacklogSnapshot(),
apiClient.getBackupDrTargetInventory(),
apiClient.getBackupDrReadinessMatrix(),
apiClient.getBackupNotificationPolicy(),
])
.then(([inventoryData, backlogData]) => {
.then(([inventoryData, backlogData, targetData, readinessData, policyData]) => {
setSnapshot(inventoryData)
setBacklog(backlogData)
setBackupTargets(targetData)
setBackupReadiness(readinessData)
setBackupPolicy(policyData)
setError(false)
})
.catch(() => setError(true))
@@ -224,6 +256,32 @@ export function AutomationInventoryTab() {
.filter(group => group.items.length > 0)
}, [backlog])
const visibleReadinessRows = useMemo(() => {
if (!backupReadiness) return []
const priority = { blocked: 0, action_required: 1, deferred: 2, ready: 3 } as Record<string, number>
return [...backupReadiness.readiness_rows]
.sort((a, b) => {
const left = priority[a.overall_readiness] ?? 4
const right = priority[b.overall_readiness] ?? 4
if (left !== right) return left - right
return a.target_id.localeCompare(b.target_id)
})
.slice(0, 8)
}, [backupReadiness])
const visibleBackupTargets = useMemo(() => {
if (!backupTargets) return []
const priority = { critical: 0, high: 1, medium: 2, low: 3 } as Record<string, number>
return [...backupTargets.backup_targets]
.sort((a, b) => {
const left = priority[a.risk_level] ?? 4
const right = priority[b.risk_level] ?? 4
if (left !== right) return left - right
return a.target_id.localeCompare(b.target_id)
})
.slice(0, 6)
}, [backupTargets])
if (loading) {
return (
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-kpi-grid">
@@ -237,7 +295,7 @@ export function AutomationInventoryTab() {
)
}
if (error || !snapshot || !backlog) {
if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy) {
return (
<div style={{ padding: 20 }}>
<GlassCard variant="subtle" padding="lg">
@@ -275,10 +333,23 @@ export function AutomationInventoryTab() {
const criticalAssets = snapshot.assets.filter(asset => asset.risk_level === 'critical').length
const completedTasks = snapshot.tasks.filter(task => task.status === 'done').length
const p1BacklogCount = backlog.rollups.by_priority.P1 ?? 0
const readyBackupRows = backupReadiness.rollups.by_overall_readiness.ready ?? 0
const actionRequiredBackupRows = backupReadiness.rollups.by_overall_readiness.action_required ?? 0
const blockedBackupRows = backupReadiness.rollups.by_overall_readiness.blocked ?? 0
const suppressedSuccessRules = backupPolicy.rollups.by_decision.suppress_immediate_success ?? 0
const immediateEscalationRules = backupPolicy.rollups.by_decision.escalate_immediate ?? 0
const blockedApprovals = Object.entries(snapshot.approval_boundaries)
.filter(([, allowed]) => allowed === false)
.map(([key]) => key)
const statusLabel = (value: string) => {
try {
return t(`backupEvidence.statuses.${value}` as never)
} catch {
return value
}
}
return (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
<GlassCard variant="subtle" padding="md">
@@ -450,6 +521,125 @@ export function AutomationInventoryTab() {
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
<Archive size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('backupEvidence.title')}
</span>
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
{t('backupEvidence.source', {
targets: formatDateTime(backupTargets.generated_at),
readiness: formatDateTime(backupReadiness.generated_at),
policy: formatDateTime(backupPolicy.generated_at),
})}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-backup-kpi-grid">
<MetricCard label={t('backupEvidence.metrics.targets')} value={backupTargets.rollups.total_targets} icon={<HardDrive size={16} />} />
<MetricCard label={t('backupEvidence.metrics.ready')} value={readyBackupRows} tone="ok" icon={<ShieldCheck size={16} />} />
<MetricCard label={t('backupEvidence.metrics.actionRequired')} value={actionRequiredBackupRows} tone="warn" icon={<AlertTriangle size={16} />} />
<MetricCard label={t('backupEvidence.metrics.blocked')} value={blockedBackupRows} tone={blockedBackupRows > 0 ? 'danger' : 'ok'} icon={<Lock size={16} />} />
<MetricCard label={t('backupEvidence.metrics.successSuppressed')} value={suppressedSuccessRules} tone="ok" icon={<BellOff size={16} />} />
<MetricCard label={t('backupEvidence.metrics.immediateEscalations')} value={immediateEscalationRules} tone="warn" icon={<BellRing size={16} />} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.35fr) minmax(0, 0.65fr)', gap: 12 }} className="automation-inventory-backup-evidence-grid">
<div style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 11, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('backupEvidence.readinessTitle')}
</span>
<Chip value={backupReadiness.program_status.current_task_id} muted />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }} className="automation-inventory-backup-readiness-grid">
{visibleReadinessRows.map(row => (
<div key={row.target_id} style={{ padding: 11, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#faf9f3', minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 12,
fontWeight: 700,
color: '#141413',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{row.display_name}
</span>
<Chip value={statusLabel(row.overall_readiness)} muted={row.overall_readiness === 'ready'} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
<Chip value={`${t('backupEvidence.labels.freshness')}: ${statusLabel(row.freshness_status)}`} muted={evidenceTone(row.freshness_status) === 'ok'} />
<Chip value={`${t('backupEvidence.labels.integrity')}: ${statusLabel(row.integrity_status)}`} muted={evidenceTone(row.integrity_status) === 'ok'} />
<Chip value={`${t('backupEvidence.labels.restore')}: ${statusLabel(row.restore_drill_status)}`} muted={evidenceTone(row.restore_drill_status) === 'ok'} />
<Chip value={`${t('backupEvidence.labels.offsite')}: ${statusLabel(row.offsite_status)}`} muted={evidenceTone(row.offsite_status) === 'ok'} />
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
{row.blocker_summary || t('backupEvidence.noBlocker')}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
<Chip value={row.gate_status ? statusLabel(row.gate_status) : '--'} muted />
<Chip value={row.evidence_refs[0] ?? t('backupEvidence.noEvidence')} muted />
</div>
</div>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
<div style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('backupEvidence.policyTitle')}
</span>
{backupPolicy.policy_rules.slice(0, 5).map(rule => (
<div key={rule.rule_id} style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, fontWeight: 700, color: '#141413', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rule.rule_id}
</span>
<Chip value={statusLabel(rule.decision)} muted={rule.decision === 'suppress_immediate_success'} />
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
{rule.message_contract}
</div>
</div>
))}
</div>
<div style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('backupEvidence.targetsTitle')}
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{visibleBackupTargets.map(target => (
<div key={target.target_id} style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 12, fontWeight: 700, color: '#141413', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{target.display_name}
</span>
<Chip value={target.rpo} muted />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<Chip value={target.risk_level} />
<Chip value={target.owner_host} muted />
<Chip value={target.primary_script} muted />
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</GlassCard>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.2fr) minmax(0, 0.8fr)', gap: 12 }} className="automation-inventory-bottom-grid">
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
@@ -511,6 +701,9 @@ export function AutomationInventoryTab() {
.automation-inventory-workstream-grid,
.automation-inventory-domain-grid,
.automation-inventory-backlog-grid,
.automation-inventory-backup-kpi-grid,
.automation-inventory-backup-evidence-grid,
.automation-inventory-backup-readiness-grid,
.automation-inventory-bottom-grid,
.automation-inventory-task-grid {
grid-template-columns: 1fr !important;

View File

@@ -261,6 +261,21 @@ export const apiClient = {
const res = await fetch(`${API_BASE_URL}/agents/automation-backlog-snapshot`)
return handleResponse<AiAgentAutomationBacklogSnapshot>(res)
},
async getBackupDrTargetInventory() {
const res = await fetch(`${API_BASE_URL}/agents/backup-dr-target-inventory`)
return handleResponse<BackupDrTargetInventorySnapshot>(res)
},
async getBackupDrReadinessMatrix() {
const res = await fetch(`${API_BASE_URL}/agents/backup-dr-readiness-matrix`)
return handleResponse<BackupDrReadinessMatrixSnapshot>(res)
},
async getBackupNotificationPolicy() {
const res = await fetch(`${API_BASE_URL}/agents/backup-notification-policy`)
return handleResponse<BackupNotificationPolicySnapshot>(res)
},
}
// =========================================================================
@@ -721,3 +736,126 @@ export interface AiAgentAutomationBacklogSnapshot {
false
>
}
export interface BackupDrTargetInventorySnapshot {
schema_version: 'backup_dr_target_inventory_v1'
generated_at: string
source_refs: string[]
program_status: {
overall_completion_percent: number
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
current_task_id: string
next_task_id: string
read_only_mode: true
}
rollups: {
total_targets: number
by_status: Record<string, number>
by_target_type: Record<string, number>
by_gate_status: Record<string, number>
blocked_target_ids: string[]
}
backup_targets: Array<{
target_id: string
display_name: string
target_type: string
status: string
risk_level: 'low' | 'medium' | 'high' | 'critical'
owner_host: string
primary_script: string
schedule: string
rpo: string
storage_class: string
storage_ref: string
offsite_policy: string
automation_gate_status: string
restore_gate_status: string
secret_policy: string
evidence_refs: string[]
next_action: string
}>
approval_boundaries: Record<string, false>
operation_boundaries: Record<string, boolean>
}
export interface BackupDrReadinessMatrixSnapshot {
schema_version: 'backup_dr_readiness_matrix_v1'
generated_at: string
source_target_inventory_ref: string
source_refs: string[]
program_status: {
overall_completion_percent: number
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
current_task_id: string
next_task_id: string
read_only_mode: true
}
rollups: {
total_rows: number
by_overall_readiness: Record<string, number>
by_restore_drill_status: Record<string, number>
by_offsite_status: Record<string, number>
blocked_row_ids: string[]
action_required_row_ids: string[]
}
readiness_rows: Array<{
target_id: string
display_name: string
overall_readiness: string
freshness_status: string
integrity_status: string
restore_drill_status: string
offsite_status: string
notification_policy: string
gate_status: string
evidence_level: string
evidence_refs: string[]
blocker_summary: string
next_action: string
}>
approval_boundaries: Record<string, false>
operation_boundaries: Record<string, boolean>
}
export interface BackupNotificationPolicySnapshot {
schema_version: 'backup_notification_policy_v1'
generated_at: string
source_readiness_matrix_ref: string
source_refs: string[]
program_status: {
overall_completion_percent: number
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
current_task_id: string
next_task_id: string
read_only_mode: true
}
rollups: {
total_rules: number
by_decision: Record<string, number>
immediate_escalation_rule_ids: string[]
suppressed_success_rule_ids: string[]
}
notification_channels: Array<{
channel_id: string
purpose: string
immediate_allowed: boolean
success_immediate_allowed: boolean
requires_operator_action: boolean
}>
policy_rules: Array<{
rule_id: string
event_kind: string
backup_state: string
severity: string
decision: string
channels: string[]
owner_agent: string
requires_incident: boolean
requires_approval_record: boolean
message_contract: string
evidence_refs: string[]
}>
daily_summary_contract: Record<string, unknown>
approval_boundaries: Record<string, false>
operation_boundaries: Record<string, boolean>
}