Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
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>
312 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|