feat(web/drift): Config Drift Detection 頁面 — Phase 25 P2 前端
Some checks are pending
CD Pipeline / build-and-deploy (push) Waiting to run

- drift/page.tsx: 漂移偵測頁面(報告列表 + 手動掃描)
- sidebar.tsx: 加入 drift nav item(Diff icon,ops section)
- i18n: zh-TW + en 新增 nav.drift + drift.* keys

功能:
- GET /api/v1/drift/reports → 顯示最近 20 份報告
- POST /api/v1/drift/scan → 手動觸發掃描,顯示結果 banner
- DriftLevelBadge: 高/中/低 漂移計數
- StatusBadge: pending/resolved/ignored
- Nemotron 意圖分析顯示

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-04 18:08:05 +08:00
parent 688146ef9c
commit aea16c87ce
4 changed files with 395 additions and 5 deletions

View File

@@ -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"
}
}
}

View File

@@ -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": "已忽略"
}
}
}

View File

@@ -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 (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-healthy/10 text-status-healthy border border-status-healthy/20">
<CheckCircle2 size={10} />
{t('noDrift')}
</span>
)
}
return (
<div className="flex items-center gap-1.5">
{high > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-critical/10 text-status-critical border border-status-critical/20">
<AlertTriangle size={10} />
{t('highCount')} {high}
</span>
)}
{medium > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-warning/10 text-status-warning border border-status-warning/20">
<Info size={10} />
{t('mediumCount')} {medium}
</span>
)}
{info > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-neutral-100 text-neutral-500 border border-neutral-200">
{t('infoCount')} {info}
</span>
)}
</div>
)
}
function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) {
const styles: Record<string, string> = {
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 (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium border',
styles[status] ?? styles.ignored
)}>
{t(status)}
</span>
)
}
// =============================================================================
// Main Page
// =============================================================================
export default function DriftPage({ params }: { params: { locale: string } }) {
const t = useTranslations('drift')
const [reports, setReports] = useState<DriftReport[]>([])
const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState(false)
const [scanResult, setScanResult] = useState<ScanResult | null>(null)
const [error, setError] = useState<string | null>(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 (
<AppLayout locale={params.locale}>
<div className="flex flex-col h-full overflow-auto bg-white">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-100">
<div className="flex items-center gap-3">
<Diff size={18} className="text-neutral-400" />
<div>
<h1 className="text-[13px] font-semibold text-neutral-800 leading-tight">
{t('title')}
</h1>
<p className="text-[11px] text-neutral-400 leading-tight mt-0.5">
{t('subtitle')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchReports}
disabled={loading}
className="p-1.5 rounded text-neutral-400 hover:text-neutral-600 hover:bg-neutral-50 transition-colors disabled:opacity-40"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={handleScan}
disabled={scanning}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-[11px] font-medium transition-colors',
scanning
? 'bg-neutral-100 text-neutral-400 cursor-not-allowed'
: 'bg-neutral-900 text-white hover:bg-neutral-700'
)}
>
{scanning ? (
<>
<RefreshCw size={11} className="animate-spin" />
{t('scanning')}
</>
) : (
<>
<GitMerge size={11} />
{t('scan')}
</>
)}
</button>
</div>
</div>
{/* Scan Result Banner */}
{scanResult && (
<div className={cn(
'mx-6 mt-4 px-4 py-3 rounded border text-[12px]',
scanResult.has_critical_drift
? 'bg-status-critical/5 border-status-critical/20 text-status-critical'
: 'bg-status-healthy/5 border-status-healthy/20 text-status-healthy'
)}>
<div className="flex items-center gap-2 font-medium">
{scanResult.has_critical_drift
? <AlertTriangle size={13} />
: <CheckCircle2 size={13} />
}
{scanResult.summary}
{(scanResult.high_count > 0 || scanResult.medium_count > 0) && (
<span className="ml-2 text-neutral-500 font-normal">
High: {scanResult.high_count}, Medium: {scanResult.medium_count}
</span>
)}
</div>
{scanResult.interpretation && (
<p className="mt-1.5 text-neutral-600 font-normal pl-5">
{scanResult.interpretation}
</p>
)}
</div>
)}
{/* Error */}
{error && (
<div className="mx-6 mt-4 px-4 py-3 rounded border border-status-critical/20 bg-status-critical/5 text-status-critical text-[12px]">
{error}
</div>
)}
{/* Content */}
<div className="flex-1 px-6 py-4">
{loading && reports.length === 0 ? (
<div className="flex items-center justify-center h-32 text-neutral-400">
<RefreshCw size={16} className="animate-spin mr-2" />
<span className="text-[12px]">Loading...</span>
</div>
) : reports.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-neutral-400">
<Terminal size={32} className="mb-3 opacity-30" />
<p className="text-[13px] font-medium text-neutral-500">{t('noReports')}</p>
<p className="text-[11px] text-neutral-400 mt-1 text-center max-w-xs">{t('noReportsHint')}</p>
</div>
) : (
<div className="space-y-2">
{reports.map((report) => (
<div
key={report.report_id}
className="border border-neutral-100 rounded-lg px-4 py-3 hover:border-neutral-200 transition-colors"
>
<div className="flex items-center justify-between gap-3">
{/* Left: ID + time */}
<div className="flex items-center gap-3 min-w-0">
<code className="text-[11px] font-mono text-neutral-500 bg-neutral-50 px-1.5 py-0.5 rounded shrink-0">
{report.report_id.slice(0, 8)}
</code>
<DriftLevelBadge
high={report.high_count}
medium={report.medium_count}
info={report.info_count}
t={t}
/>
</div>
{/* Right: status + time */}
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={report.status} t={t} />
<span className="text-[11px] text-neutral-400 flex items-center gap-1">
<Clock size={10} />
{fmtTime(report.scanned_at)}
</span>
</div>
</div>
{/* Interpretation */}
{report.interpretation && (
<p className="mt-2 text-[11px] text-neutral-500 pl-1 border-l-2 border-neutral-100">
{report.interpretation}
</p>
)}
{/* Metadata */}
<div className="flex items-center gap-4 mt-2 text-[10px] text-neutral-400">
<span>{t('namespace')}: <span className="font-mono">{report.namespace}</span></span>
<span>{t('triggeredBy')}: {report.triggered_by}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</AppLayout>
)
}

View File

@@ -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 },