Files
awoooi/apps/web/src/components/panels/DriftPanel.tsx
Your Name 64b34828a7
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m10s
CD Pipeline / build-and-deploy (push) Successful in 3m42s
CD Pipeline / post-deploy-checks (push) Successful in 1m18s
feat(drift): record remediation evidence
2026-05-19 09:13:58 +08:00

477 lines
17 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, 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 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',
acknowledged: 'bg-neutral-100 text-neutral-500 border-neutral-200',
rolled_back: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
adopted: '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>
)
}
function DriftFingerprintStateCard({
state,
t,
}: {
state: DriftFingerprintState | null
t: (k: string, values?: Record<string, string | number>) => string
}) {
if (!state) return null
return (
<div className="mx-6 mt-4 border border-neutral-200 bg-neutral-50 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<Fingerprint size={15} className="mt-0.5 shrink-0 text-neutral-500" />
<div className="min-w-0">
<p className="text-[12px] font-semibold text-neutral-800">
{t('fingerprintState.title')}
</p>
<p className="mt-1 truncate font-mono text-[11px] text-neutral-500">
{state.fingerprint ?? '--'}
</p>
</div>
</div>
<span className="border border-status-warning/20 bg-status-warning/10 px-2 py-0.5 text-[11px] font-medium text-status-warning">
{t('fingerprintState.occurrences', { count: state.occurrences_12h ?? 0 })}
</span>
</div>
<div className="mt-3 grid gap-2 text-[11px] text-neutral-500 md:grid-cols-2">
<p>{t('fingerprintState.report', { report: state.latest_report_id ?? '--' })}</p>
<p>{t('fingerprintState.state', { state: state.fsm_state ?? '--' })}</p>
<p>{t('fingerprintState.next', { step: state.next_step ?? '--' })}</p>
<p>
{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-3 flex flex-wrap items-center gap-2 text-[11px] text-neutral-500">
<span className="inline-flex items-center gap-1 border border-neutral-200 bg-white px-2 py-0.5">
<GitPullRequest size={11} />
{t('fingerprintState.pr', {
pr: state.open_pr?.number ?? '--',
zeroDiff: String(state.open_pr?.is_zero_diff ?? false),
})}
</span>
<span className="border border-neutral-200 bg-white px-2 py-0.5">
{t('fingerprintState.p0Dedup', {
hours: state.p0_escalation?.dedup_window_hours ?? 24,
})}
</span>
<span className="border border-status-healthy/20 bg-status-healthy/10 px-2 py-0.5 text-status-healthy">
{t('fingerprintState.remediation', {
status: state.latest_remediation?.remediation_status ?? '--',
report: state.latest_remediation?.verification_report_id ?? '--',
})}
</span>
</div>
{state.latest_remediation ? (
<div className="mt-3 border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-5 text-neutral-500">
<p>
{t('fingerprintState.remediationKind', {
kind: state.latest_remediation.remediation_kind ?? '--',
})}
</p>
<p>
{t('fingerprintState.remediationVerification', {
summary: state.latest_remediation.verification_summary?.summary ?? '--',
})}
</p>
<p>
{t('fingerprintState.remediationNote', {
note: state.latest_remediation.note ?? '--',
})}
</p>
</div>
) : null}
</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="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>
{formatInterpretation(scanResult.interpretation) && (
<p className="mt-1.5 text-neutral-600 font-normal pl-5">
{formatInterpretation(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>
)}
<DriftFingerprintStateCard state={fingerprintState} t={t} />
{/* 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>
{formatInterpretation(report.interpretation) && (
<p className="mt-2 text-[11px] text-neutral-500 pl-1 border-l-2 border-neutral-100">
{formatInterpretation(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>
)
}