feat(web): 顯示 Backup DR 治理證據
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user