Files
awoooi/apps/web/src/components/panels/DriftPanel.tsx
OG T 7934ade3a6
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
refactor(web): 全部 13 Panel 抽取完成 + 整合頁面雙重 AppLayout 修正
Panel 抽取 (13 個):
- MonitoringPanel, ApmPanel, ErrorsPanel, AppsPanel, ServicesPanel
- AutoRepairPanel, NeuralCommandPanel, DriftPanel
- DeploymentsPanel, TicketsPanel, CostPanel, ActionLogsPanel, BillingPanel

整合頁面更新 (全部使用 Panel,無雙重 AppLayout):
- /observability: 5 Panel
- /automation: 3 Panel
- /operations: 5 Panel

首席架構師 I2 問題已解決

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:05:37 +08:00

312 lines
11 KiB
TypeScript

'use client'
/**
* DriftPanel — 配置漂移偵測面板 (不含 AppLayout)
* ==================================================
* Sprint 5: 從 /drift/page.tsx 抽取
* 供原始頁面和整合頁面 (/automation) 共用
*
* 建立時間: 2026-04-09 (台北時區)
*/
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
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>
)
}
// =============================================================================
// DriftPanel
// =============================================================================
export function DriftPanel() {
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({ namespaces: ['awoooi-prod'], triggered_by: 'web_manual' }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: ScanResult = await res.json()
setScanResult(data)
await fetchReports()
} catch (e) {
setError(e instanceof Error ? e.message : 'Scan failed')
} finally {
setScanning(false)
}
}
return (
<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">
{t('highCount')} {scanResult.high_count}, {t('mediumCount')} {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]">{t('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">
<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>
<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>
{report.interpretation && (
<p className="mt-2 text-[11px] text-neutral-500 pl-1 border-l-2 border-neutral-100">
{report.interpretation}
</p>
)}
<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>
)
}