diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ca447870..274bcd8d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -58,7 +58,8 @@ "users": "Users", "notifications": "Notifications", "billing": "Billing", - "help": "Help" + "help": "Help", + "drift": "Drift Detection" }, "locale": { "switch": "Switch Language", @@ -924,5 +925,36 @@ "emptyState": { "noData": "--", "comingSoon": "Integration pending" + }, + "drift": { + "title": "Config Drift Detection", + "subtitle": "GitOps Guardian — Detects drift between K8s actual state and Git YAML", + "scan": "Scan Now", + "scanning": "Scanning...", + "noReports": "No drift reports yet", + "noReportsHint": "CronJob scans hourly automatically, or click \"Scan Now\" to trigger manually", + "noDrift": "No Drift", + "reportId": "Report ID", + "scannedAt": "Scanned At", + "namespace": "Namespace", + "triggeredBy": "Triggered By", + "highCount": "High", + "mediumCount": "Medium", + "infoCount": "Info", + "status": "Status", + "driftLevel": { + "high": "High", + "medium": "Medium", + "info": "Info" + }, + "interpretation": "Nemotron Intent Analysis", + "noInterpretation": "No analysis needed (no drift)", + "rollback": "Rollback to Git", + "adopt": "Adopt Change", + "rollbackConfirm": "Rollback this resource to Git state?", + "adoptConfirm": "Adopt this change and update Git?", + "pending": "Pending", + "resolved": "Resolved", + "ignored": "Ignored" } -} \ No newline at end of file +} diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 7a6e0a49..eac763e5 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -58,7 +58,8 @@ "users": "使用者", "notifications": "通知", "billing": "帳單", - "help": "說明" + "help": "說明", + "drift": "漂移偵測" }, "locale": { "switch": "切換語系", @@ -925,5 +926,36 @@ "emptyState": { "noData": "--", "comingSoon": "資料尚未整合" + }, + "drift": { + "title": "配置漂移偵測", + "subtitle": "GitOps 守門員 — 偵測 K8s 實際狀態 vs Git YAML 的漂移", + "scan": "立即掃描", + "scanning": "掃描中...", + "noReports": "目前無漂移報告", + "noReportsHint": "CronJob 每小時自動掃描,或點擊「立即掃描」手動觸發", + "noDrift": "無漂移", + "reportId": "報告 ID", + "scannedAt": "掃描時間", + "namespace": "Namespace", + "triggeredBy": "觸發來源", + "highCount": "高", + "mediumCount": "中", + "infoCount": "低", + "status": "狀態", + "driftLevel": { + "high": "高", + "medium": "中", + "info": "低" + }, + "interpretation": "Nemotron 意圖分析", + "noInterpretation": "無需分析(無漂移)", + "rollback": "覆蓋回 Git", + "adopt": "承認變更", + "rollbackConfirm": "確定要將此資源覆蓋回 Git 狀態嗎?", + "adoptConfirm": "確定要將此變更承認並更新至 Git 嗎?", + "pending": "待處理", + "resolved": "已解決", + "ignored": "已忽略" } -} \ No newline at end of file +} diff --git a/apps/web/src/app/[locale]/drift/page.tsx b/apps/web/src/app/[locale]/drift/page.tsx new file mode 100644 index 00000000..23989dff --- /dev/null +++ b/apps/web/src/app/[locale]/drift/page.tsx @@ -0,0 +1,324 @@ +'use client' + +/** + * Config Drift Detection Page - 配置漂移偵測 + * ============================================= + * Phase 25 P2: GitOps 守門員 + * 偵測 K8s 實際狀態 vs Git YAML 漂移 + * + * API: /api/v1/drift/scan, /api/v1/drift/reports + * CronJob: drift-scanner (每小時自動) + * + * 建立時間: 2026-04-04 (台北時區) + * 建立者: Claude Code (Phase 25 P2) + * 關聯設計: docs/superpowers/specs/2026-04-04-nemotron-active-defense-design.md 方向三 + */ + +import { useState, useEffect, useCallback } from 'react' +import { useTranslations } from 'next-intl' +import { AppLayout } from '@/components/layout' +import { cn } from '@/lib/utils' +import { + Diff, RefreshCw, AlertTriangle, CheckCircle2, + Clock, Terminal, GitMerge, Info, +} from 'lucide-react' + +// ============================================================================= +// Types +// ============================================================================= + +interface DriftReport { + report_id: string + scanned_at: string + namespace: string + triggered_by: string + high_count: number + medium_count: number + info_count: number + interpretation: string | null + status: 'pending' | 'resolved' | 'ignored' + created_at: string + resolved_at: string | null +} + +interface ScanResult { + report_id: string + summary: string + high_count: number + medium_count: number + info_count: number + has_critical_drift: boolean + interpretation: string | null +} + +// ============================================================================= +// Helpers +// ============================================================================= + +const getApiBase = () => { + if (typeof window === 'undefined') return '' + return process.env.NEXT_PUBLIC_API_URL ?? '' +} + +const fmtTime = (iso: string) => { + try { + return new Date(iso).toLocaleString('zh-TW', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + } catch { + return iso + } +} + +// ============================================================================= +// Sub-components +// ============================================================================= + +function DriftLevelBadge({ high, medium, info, t }: { + high: number; medium: number; info: number + t: (k: string) => string +}) { + if (high === 0 && medium === 0 && info === 0) { + return ( + + + {t('noDrift')} + + ) + } + return ( +
+ {high > 0 && ( + + + {t('highCount')} {high} + + )} + {medium > 0 && ( + + + {t('mediumCount')} {medium} + + )} + {info > 0 && ( + + {t('infoCount')} {info} + + )} +
+ ) +} + +function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) { + const styles: Record = { + pending: 'bg-status-warning/10 text-status-warning border-status-warning/20', + resolved: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20', + ignored: 'bg-neutral-100 text-neutral-400 border-neutral-200', + } + return ( + + {t(status)} + + ) +} + +// ============================================================================= +// Main Page +// ============================================================================= + +export default function DriftPage({ params }: { params: { locale: string } }) { + const t = useTranslations('drift') + const [reports, setReports] = useState([]) + const [loading, setLoading] = useState(true) + const [scanning, setScanning] = useState(false) + const [scanResult, setScanResult] = useState(null) + const [error, setError] = useState(null) + + const fetchReports = useCallback(async () => { + setLoading(true) + setError(null) + try { + const res = await fetch(`${getApiBase()}/api/v1/drift/reports?limit=20`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + setReports(data.items ?? []) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to fetch') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { fetchReports() }, [fetchReports]) + + const handleScan = async () => { + setScanning(true) + setScanResult(null) + try { + const res = await fetch(`${getApiBase()}/api/v1/drift/scan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ namespace: 'awoooi-prod', triggered_by: 'web_manual' }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data: ScanResult = await res.json() + setScanResult(data) + // Refresh reports list + await fetchReports() + } catch (e) { + setError(e instanceof Error ? e.message : 'Scan failed') + } finally { + setScanning(false) + } + } + + return ( + +
+ {/* Header */} +
+
+ +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+
+
+ + +
+
+ + {/* Scan Result Banner */} + {scanResult && ( +
+
+ {scanResult.has_critical_drift + ? + : + } + {scanResult.summary} + {(scanResult.high_count > 0 || scanResult.medium_count > 0) && ( + + — High: {scanResult.high_count}, Medium: {scanResult.medium_count} + + )} +
+ {scanResult.interpretation && ( +

+ {scanResult.interpretation} +

+ )} +
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Content */} +
+ {loading && reports.length === 0 ? ( +
+ + Loading... +
+ ) : reports.length === 0 ? ( +
+ +

{t('noReports')}

+

{t('noReportsHint')}

+
+ ) : ( +
+ {reports.map((report) => ( +
+
+ {/* Left: ID + time */} +
+ + {report.report_id.slice(0, 8)} + + +
+ {/* Right: status + time */} +
+ + + + {fmtTime(report.scanned_at)} + +
+
+ {/* Interpretation */} + {report.interpretation && ( +

+ {report.interpretation} +

+ )} + {/* Metadata */} +
+ {t('namespace')}: {report.namespace} + {t('triggeredBy')}: {report.triggered_by} +
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 6aa91136..fdb4d9dd 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -33,7 +33,7 @@ import { Wrench, Package, Ticket, DollarSign, Zap, FileText, BookOpen, Terminal, AppWindow, Server, Users, BellRing, CreditCard, HelpCircle, Settings, - ChevronLeft, ChevronRight, + ChevronLeft, ChevronRight, Diff, } from 'lucide-react' // Phase 8.0 #15: 改用 approval store SSE (移除 polling) import { useApprovalStore } from '@/stores/approval.store' @@ -94,6 +94,8 @@ const NAV_SECTIONS: NavSection[] = [ sectionLabel: '', items: [ { id: 'auto-repair', href: '/auto-repair', labelKey: 'autoRepair', Icon: Wrench }, + // 2026-04-04 Claude Code: Phase 25 P2 — Config Drift Detection + { id: 'drift', href: '/drift', labelKey: 'drift', Icon: Diff }, { id: 'deployments', href: '/deployments', labelKey: 'deployments', Icon: Package }, { id: 'tickets', href: '/tickets', labelKey: 'tickets', Icon: Ticket }, { id: 'cost', href: '/cost', labelKey: 'cost', Icon: DollarSign },