Files
awoooi/apps/web/src/components/panels/DriftPanel.tsx

494 lines
19 KiB
TypeScript

'use client'
/**
* DriftPanel — 配置漂移偵測面板 (不含 AppLayout)
* ==================================================
* Sprint 5: 從 /drift/page.tsx 抽取
* 供原始頁面和整合頁面 (/automation) 共用
*
* 建立時間: 2026-04-09 (台北時區)
* 更新時間: 2026-07-04 — UI/UX 全面升級 (Option A 明亮模式)
*/
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, Fingerprint, GitPullRequest,
} 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: DriftInterpretationValue
status: 'pending' | 'resolved' | 'acknowledged' | 'rolled_back' | 'adopted' | '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: DriftInterpretationValue
}
type DriftInterpretationValue =
| string
| {
intent?: string | null
explanation?: string | null
risk?: string | null
confidence?: number | null
}
| null
interface DriftFingerprintState {
namespace?: string
fingerprint?: string
latest_report_id?: string
latest_status?: string
summary?: string
occurrences_12h?: number
fsm_state?: string
next_step?: string
high_count?: number
medium_count?: number
info_count?: number
open_pr?: {
number?: number | string | null
state?: string | null
file_count?: number | null
commit_count?: number | null
is_zero_diff?: boolean | null
lookup_error?: string | null
} | null
latest_handoff?: {
handoff_status?: string | null
} | null
latest_remediation?: {
remediation_kind?: string | null
remediation_status?: string | null
verification_report_id?: string | null
verification_summary?: {
summary?: string | null
is_no_drift?: boolean | null
} | null
note?: string | null
created_at?: string | null
} | null
p0_escalation?: {
suppresses_repeated_p0?: boolean | null
dedup_window_hours?: number | null
} | null
writes_drift_status?: boolean | null
writes_incident_state?: boolean | null
writes_auto_repair_result?: boolean | null
writes_ticket?: boolean | 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
}
}
const formatInterpretation = (value: DriftInterpretationValue) => {
if (!value) return null
if (typeof value === 'string') return value
const meta = [
value.intent,
value.risk,
typeof value.confidence === 'number' ? `${Math.round(value.confidence * 100)}%` : null,
].filter(Boolean).join(' / ')
const explanation = value.explanation || ''
if (meta && explanation) return `${meta}${explanation}`
return meta || explanation || null
}
// =============================================================================
// 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 rounded bg-[#f0faf2] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#17602a]">
<CheckCircle2 className="h-3 w-3" />
{t('noDrift')}
</span>
)
}
return (
<div className="flex items-center gap-1.5">
{high > 0 && (
<span className="inline-flex items-center gap-1 rounded bg-[#fff1f0] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#9f2f25]">
<AlertTriangle className="h-3 w-3" />
{t('highCount')} {high}
</span>
)}
{medium > 0 && (
<span className="inline-flex items-center gap-1 rounded bg-[#fff7e8] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#8a5a08]">
<Info className="h-3 w-3" />
{t('mediumCount')} {medium}
</span>
)}
{info > 0 && (
<span className="inline-flex items-center gap-1 rounded bg-[#f5f4ed] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#5f5b52]">
{t('infoCount')} {info}
</span>
)}
</div>
)
}
function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) {
const styles: Record<string, string> = {
pending: 'bg-[#fff7e8] text-[#8a5a08] border-[#d9b36f]',
resolved: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
acknowledged: 'bg-[#f5f4ed] text-[#5f5b52] border-[#e0ddd4]',
rolled_back: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
adopted: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
ignored: 'bg-[#faf9f3] text-[#77736a] border-[#e0ddd4]',
}
return (
<span className={cn(
'inline-flex items-center rounded-md border px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest',
styles[status] ?? styles.ignored
)}>
{t(status)}
</span>
)
}
function DriftFingerprintStateCard({
state,
t,
}: {
state: DriftFingerprintState | null
t: (k: string, values?: Record<string, string | number>) => string
}) {
if (!state) return null
return (
<div className="mb-6 overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
<div className="flex min-w-0 items-start gap-2">
<Fingerprint className="mt-0.5 h-4 w-4 shrink-0 text-[#77736a]" />
<div className="min-w-0">
<p className="font-semibold text-[#141413]">
{t('fingerprintState.title')}
</p>
<p className="mt-1 truncate font-mono text-[11px] text-[#77736a]">
{state.fingerprint ?? '--'}
</p>
</div>
</div>
<span className="rounded bg-[#fff7e8] px-2 py-0.5 font-mono text-[11px] font-bold tracking-widest text-[#8a5a08]">
{t('fingerprintState.occurrences', { count: state.occurrences_12h ?? 0 })}
</span>
</div>
<div className="px-5 py-4">
<div className="grid gap-3 text-sm text-[#5f5b52] sm:grid-cols-2">
<p className="flex items-center gap-2"><span className="font-semibold text-[#141413]">Report:</span> <code className="rounded bg-[#f5f4ed] px-1.5 py-0.5 font-mono text-xs">{state.latest_report_id ?? '--'}</code></p>
<p className="flex items-center gap-2"><span className="font-semibold text-[#141413]">State:</span> <span className="font-mono text-xs text-[#d97757]">{state.fsm_state ?? '--'}</span></p>
<p className="flex items-center gap-2"><span className="font-semibold text-[#141413]">Next:</span> <span className="font-mono text-xs text-[#4A90D9]">{state.next_step ?? '--'}</span></p>
<p className="flex items-center gap-2 font-mono text-[11px]">
{t('fingerprintState.writes', {
drift: String(state.writes_drift_status ?? false),
incident: String(state.writes_incident_state ?? false),
repair: String(state.writes_auto_repair_result ?? false),
ticket: String(state.writes_ticket ?? false),
})}
</p>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-[#faf9f3] px-2.5 py-1 font-mono text-[11px] text-[#5f5b52]">
<GitPullRequest className="h-3 w-3" />
{t('fingerprintState.pr', {
pr: state.open_pr?.number ?? '--',
zeroDiff: String(state.open_pr?.is_zero_diff ?? false),
})}
</span>
<span className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] px-2.5 py-1 font-mono text-[11px] text-[#5f5b52]">
{t('fingerprintState.p0Dedup', {
hours: state.p0_escalation?.dedup_window_hours ?? 24,
})}
</span>
<span className="rounded-lg border border-[#8fc29a] bg-[#f0faf2] px-2.5 py-1 font-mono text-[11px] text-[#17602a]">
{t('fingerprintState.remediation', {
status: state.latest_remediation?.remediation_status ?? '--',
report: state.latest_remediation?.verification_report_id ?? '--',
})}
</span>
</div>
{state.latest_remediation ? (
<div className="mt-4 rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3 text-[11px] leading-5 text-[#5f5b52]">
<p>
<strong className="text-[#141413] font-semibold">{t('fingerprintState.remediationKind', {
kind: '',
})}</strong> {state.latest_remediation.remediation_kind ?? '--'}
</p>
<p className="mt-1">
<strong className="text-[#141413] font-semibold">{t('fingerprintState.remediationVerification', {
summary: '',
})}</strong> {state.latest_remediation.verification_summary?.summary ?? '--'}
</p>
<p className="mt-1">
<strong className="text-[#141413] font-semibold">{t('fingerprintState.remediationNote', {
note: '',
})}</strong> {state.latest_remediation.note ?? '--'}
</p>
</div>
) : null}
</div>
</div>
)
}
// =============================================================================
// 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 [fingerprintState, setFingerprintState] = useState<DriftFingerprintState | 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()
const nextReports = data.items ?? []
setReports(nextReports)
const latestReportId = nextReports[0]?.report_id
if (latestReportId) {
const stateRes = await fetch(
`${getApiBase()}/api/v1/drift/fingerprints/state?report_id=${encodeURIComponent(latestReportId)}`
)
setFingerprintState(stateRes.ok ? await stateRes.json() : null)
} else {
setFingerprintState(null)
}
} 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="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
{/* Header */}
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
Infrastructure
</p>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-bold text-[#141413]">
{t('title')}
</h1>
<p className="mt-1 text-sm text-[#77736a]">
{t('subtitle')}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchReports}
disabled={loading}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} aria-hidden="true" />
</button>
<button
onClick={handleScan}
disabled={scanning}
className={cn(
'inline-flex h-9 items-center gap-2 rounded-lg px-4 font-mono text-sm font-bold shadow-sm transition-all',
scanning
? 'pointer-events-none bg-[#e0ddd4] text-[#77736a]'
: 'bg-[#141413] text-white hover:bg-[#141413]/80 hover:shadow-md'
)}
>
{scanning ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
{t('scanning')}
</>
) : (
<>
<GitMerge className="h-4 w-4" />
{t('scan')}
</>
)}
</button>
</div>
</div>
{/* Scan Result Banner */}
{scanResult && (
<div className={cn(
'mb-6 overflow-hidden rounded-xl border p-4 shadow-sm',
scanResult.has_critical_drift
? 'border-[#e2a29b] bg-[#fff1f0]'
: 'border-[#8fc29a] bg-[#f0faf2]'
)}>
<div className="flex items-center gap-2 font-semibold">
{scanResult.has_critical_drift
? <AlertTriangle className="h-5 w-5 text-[#9f2f25]" />
: <CheckCircle2 className="h-5 w-5 text-[#17602a]" />
}
<span className={scanResult.has_critical_drift ? 'text-[#9f2f25]' : 'text-[#17602a]'}>
{scanResult.summary}
</span>
{(scanResult.high_count > 0 || scanResult.medium_count > 0) && (
<span className={cn('ml-2 font-mono text-sm', scanResult.has_critical_drift ? 'text-[#9f2f25]/80' : 'text-[#17602a]/80')}>
{t('highCount')} {scanResult.high_count}, {t('mediumCount')} {scanResult.medium_count}
</span>
)}
</div>
{formatInterpretation(scanResult.interpretation) && (
<p className={cn('mt-2 pl-7 font-mono text-sm', scanResult.has_critical_drift ? 'text-[#9f2f25]/90' : 'text-[#17602a]/90')}>
{formatInterpretation(scanResult.interpretation)}
</p>
)}
</div>
)}
{/* Error */}
{error && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-[#9f2f25]" aria-hidden="true" />
<div>
<p className="font-semibold text-[#141413]">Error</p>
<p className="mt-1 font-mono text-xs text-[#9f2f25]">{error}</p>
</div>
</div>
)}
<DriftFingerprintStateCard state={fingerprintState} t={t} />
{/* Reports List */}
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
<div className="flex items-center gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
<Diff className="h-4 w-4 text-[#77736a]" aria-hidden="true" />
<h2 className="font-semibold text-[#141413]">
Scan Reports
</h2>
</div>
<div className="p-5">
{loading && reports.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-[#d8d3c7]" />
<span className="mt-3 text-sm text-[#77736a]">{t('loading')}</span>
</div>
) : reports.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[#d8d3c7] bg-[#f5f4ed] py-12 text-[#77736a]">
<Terminal className="mb-3 h-8 w-8 opacity-50" />
<span className="font-medium text-[#141413]">{t('noReports')}</span>
<span className="mt-1 text-sm">{t('noReportsHint')}</span>
</div>
) : (
<div className="space-y-3">
{reports.map((report) => (
<div
key={report.report_id}
className="rounded-xl border border-[#e0ddd4] p-4 transition-colors hover:border-[#d8d3c7] hover:bg-[#faf9f3]"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
<code className="rounded bg-[#f5f4ed] px-2 py-1 font-mono text-xs font-bold text-[#5f5b52]">
{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-3 shrink-0">
<StatusBadge status={report.status} t={t} />
<span className="flex items-center gap-1 font-mono text-xs text-[#77736a]">
<Clock className="h-3 w-3" />
{fmtTime(report.scanned_at)}
</span>
</div>
</div>
{formatInterpretation(report.interpretation) && (
<p className="mt-3 border-l-2 border-[#e0ddd4] pl-3 font-mono text-sm text-[#5f5b52]">
{formatInterpretation(report.interpretation)}
</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-x-6 gap-y-2 font-mono text-xs text-[#77736a]">
<span><strong className="text-[#141413] font-semibold">{t('namespace')}:</strong> {report.namespace}</span>
<span><strong className="text-[#141413] font-semibold">{t('triggeredBy')}:</strong> {report.triggered_by}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}