feat(web/drift): Config Drift Detection 頁面 — Phase 25 P2 前端
Some checks are pending
CD Pipeline / build-and-deploy (push) Waiting to run
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "已忽略"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
324
apps/web/src/app/[locale]/drift/page.tsx
Normal file
324
apps/web/src/app/[locale]/drift/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user