import { useEffect, useState } from 'react'; import { Check, TriangleAlert, CircleSlash, WifiOff, RefreshCw, FileClock, FileCheck2, FileQuestion, type LucideIcon, } from 'lucide-react'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui/toast-store'; import { formatTimeAgo } from '@/lib/relativeTime'; import { useNodes } from '@/context/NodeContext'; // Mirrors the backend payload shape (the frontend never imports backend). type StackDriftStatus = 'in-sync' | 'drifted' | 'missing-runtime' | 'unreachable'; type DriftFindingKind = | 'service-missing' | 'service-undeclared' | 'image-mismatch' | 'ports-mismatch' | 'network-undeclared' | 'network-missing' | 'managed-path-conflict'; interface StackDriftFinding { kind: DriftFindingKind; service: string; detail: string; expected?: string; actual?: string; } interface DriftTemporal { hasBaseline: boolean; sourceChanged: boolean; renderedChanged: boolean; } interface DriftLedgerEntry { service: string; kind: DriftFindingKind; message: string; detectedAt: number; resolvedAt: number | null; } interface StackDriftReport { stack: string; status: StackDriftStatus; hasComposeFile: boolean; hasContainers: boolean; findings: StackDriftFinding[]; parseError?: string; // Optional so a report from an older remote node (no ledger layer) still renders. temporal?: DriftTemporal; ledger?: DriftLedgerEntry[]; // When the ledger was last reconciled (re-check, deploy, or background scan); null // if never. The history is "as of" this time, not the live status above it. lastCheckedAt?: number | null; } const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; const ACTION_CLASS = 'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40'; const CARD_CLASS = 'rounded-lg border px-3 py-2.5'; const STATUS_META: Record = { 'in-sync': { label: 'in sync', icon: Check, tone: 'border-success/40 bg-success/[0.06] text-success', line: 'Runtime matches the compose file.', }, drifted: { label: 'drifted', icon: TriangleAlert, tone: 'border-warning/40 bg-warning/[0.06] text-warning', line: 'Runtime differs from the compose file.', }, 'missing-runtime': { label: 'not running', icon: CircleSlash, tone: 'border-muted bg-card/40 text-stat-subtitle', line: 'Defined on disk but no containers are running.', }, unreachable: { label: 'unreachable', icon: WifiOff, tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive', line: 'Docker is unreachable, so drift cannot be assessed.', }, }; const FINDING_LABEL: Record = { 'service-missing': 'service missing', 'service-undeclared': 'undeclared', 'image-mismatch': 'image', 'ports-mismatch': 'ports', 'network-undeclared': 'network', 'network-missing': 'network missing', 'managed-path-conflict': 'managed path', }; /** The temporal overlay: how the on-disk compose compares to the last deploy baseline. */ function temporalMeta(temporal: DriftTemporal): { label: string; icon: LucideIcon; tone: string; line: string; key: string } { if (!temporal.hasBaseline) { return { key: 'no-baseline', label: 'no deploy baseline', icon: FileQuestion, tone: 'border-muted bg-card/40 text-stat-subtitle', line: 'Deploy through Sencho to start tracking changes since deploy.', }; } if (temporal.sourceChanged) { return { key: 'source-changed', label: 'source changed', icon: FileClock, tone: 'border-warning/40 bg-warning/[0.06] text-warning', line: temporal.renderedChanged ? 'The compose model changed since the last deploy.' : 'The compose file changed since the last deploy (formatting only).', }; } return { key: 'matches', label: 'matches last deploy', icon: FileCheck2, tone: 'border-success/40 bg-success/[0.06] text-success', line: 'The compose source is unchanged since the last deploy.', }; } function Finding({ finding }: { finding: StackDriftFinding }) { const gitPath = finding.kind === 'managed-path-conflict'; return (
{!gitPath && ( {finding.service} )} {FINDING_LABEL[finding.kind]}
{finding.detail}
{finding.expected !== undefined && finding.actual !== undefined && (
compose {finding.expected} → running {finding.actual}
)}
); } function LedgerRow({ entry }: { entry: DriftLedgerEntry }) { const resolved = entry.resolvedAt != null; const gitPath = entry.kind === 'managed-path-conflict'; return (
{!gitPath && ( {entry.service} )} {FINDING_LABEL[entry.kind] ?? entry.kind} {resolved ? 'resolved' : 'open'}
{entry.message}
detected {formatTimeAgo(entry.detectedAt)} {entry.resolvedAt != null ? ` · resolved ${formatTimeAgo(entry.resolvedAt)}` : ''}
); } export default function DriftPanel({ stackName }: { stackName: string }) { const { activeNode } = useNodes(); const nodeId = activeNode?.id; const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); const [reloadKey, setReloadKey] = useState(0); const [rechecking, setRechecking] = useState(false); // Passive load when the stack OR active node changes (the same stack can exist on // two nodes), and on an explicit retry. Read-only: it never writes the ledger, so // opening the tab has no side effects. A failed load shows a distinct retry state // rather than a stale or blank report. useEffect(() => { let cancelled = false; const run = async () => { setLoading(true); setLoadError(false); try { const res = await apiFetch(`/stacks/${stackName}/drift`); if (cancelled) return; if (!res.ok) { setLoadError(true); toast.error('Failed to load the drift report.'); return; } setReport((await res.json()) as StackDriftReport); setLoadError(false); } catch { if (!cancelled) { setLoadError(true); toast.error('Failed to load the drift report.'); } } finally { if (!cancelled) setLoading(false); } }; void run(); return () => { cancelled = true; }; }, [stackName, nodeId, reloadKey]); // Re-check reconciles the ledger server-side (recording newly detected / resolved // findings) and returns the fresh payload, so the history reflects this check. const recheck = async () => { setRechecking(true); try { const res = await apiFetch(`/stacks/${stackName}/drift/recheck`, { method: 'POST' }); if (!res.ok) { toast.error('Failed to re-check drift.'); return; } setReport((await res.json()) as StackDriftReport); setLoadError(false); } catch { toast.error('Failed to re-check drift.'); } finally { setRechecking(false); } }; const meta = report ? STATUS_META[report.status] : null; const StatusIcon = meta?.icon; // Only render the temporal card when the payload actually carries it. A report // proxied from an older node without the ledger layer omits it; showing "no deploy // baseline" there would be misleading, so the card is left out entirely. const temporal = report?.temporal ? temporalMeta(report.temporal) : null; const TemporalIcon = temporal?.icon; const ledger = report?.ledger ?? []; // The ledger only moves on a reconcile (re-check, deploy, or background scan), so // label the history with when that last happened: a "resolved"/"open" row then // reads as the state at that check, not a claim about the live status above it. const lastChecked = report?.lastCheckedAt != null ? formatTimeAgo(report.lastCheckedAt) : null; const busy = loading || rechecking; return (
compose vs runtime
{loadError ? (
Could not load the drift report.
) : !report ? (
Checking drift…
) : ( <> {meta && StatusIcon && (
{meta.label} {report.findings.length > 0 && ( · {report.findings.length} finding{report.findings.length === 1 ? '' : 's'} )}
{meta.line}
)} {temporal && TemporalIcon && (
{temporal.label}
{temporal.line}
)} {report.parseError && (
{report.parseError}
)} {report.findings.length > 0 && (
findings
{report.findings.map((f, i) => ( ))}
)} {ledger.length > 0 && (
drift history {lastChecked && ( · checked {lastChecked} )}
{ledger.map((e, i) => ( ))}
)} )}
); }