494 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|