feat(07-01): add config history API client and timeline component

- Add ConfigChangeEntry interface and configHistoryApi.list() to api.ts
- Create ConfigHistorySection with timeline, loading skeleton, and empty state
- Poll every 60s via TanStack Query refetchInterval

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-12 23:11:46 -05:00
parent e8bf994e7d
commit 6bd24517ba
2 changed files with 120 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
import { useQuery } from '@tanstack/react-query'
import { History } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { TableSkeleton } from '@/components/ui/page-skeleton'
import { configHistoryApi } from '@/lib/api'
interface ConfigHistorySectionProps {
tenantId: string
deviceId: string
}
function formatRelativeTime(isoDate: string): string {
const date = new Date(isoDate)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHr = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHr / 24)
if (diffSec < 60) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 30) return `${diffDay}d ago`
return date.toLocaleDateString()
}
function formatAbsoluteTime(isoDate: string): string {
return new Date(isoDate).toLocaleString()
}
function LineDelta({ added, removed }: { added: number; removed: number }) {
return (
<span className="text-xs font-mono">
<span className="text-success">+{added}</span>
<span className="text-text-muted mx-0.5">/</span>
<span className="text-error">-{removed}</span>
</span>
)
}
export function ConfigHistorySection({ tenantId, deviceId }: ConfigHistorySectionProps) {
const { data: changes, isLoading } = useQuery({
queryKey: ['config-history', tenantId, deviceId],
queryFn: () => configHistoryApi.list(tenantId, deviceId),
refetchInterval: 60_000,
})
return (
<div className="rounded-lg border border-border bg-surface p-4">
<div className="flex items-center gap-2 mb-3">
<History className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-muted-foreground">Configuration History</h3>
</div>
{isLoading ? (
<TableSkeleton rows={3} />
) : !changes || changes.length === 0 ? (
<div className="flex items-center justify-center py-6">
<span className="text-xs text-text-muted">No configuration changes recorded yet.</span>
</div>
) : (
<div className="relative">
{/* Connecting line */}
<div className="absolute left-3 top-4 bottom-4 w-px bg-elevated" />
<div className="space-y-1">
{changes.map((entry) => (
<div
key={entry.id}
className="relative flex items-start gap-3 pl-8 pr-3 py-2.5 rounded-lg"
>
{/* Timeline dot */}
<div className="absolute left-2 top-3.5 h-2 w-2 rounded-full border border-border bg-elevated" />
{/* Content */}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge className="text-xs font-mono">{entry.component}</Badge>
<LineDelta added={entry.lines_added} removed={entry.lines_removed} />
</div>
<p className="text-sm text-text-primary">{entry.summary}</p>
<span
className="text-xs text-text-muted"
title={formatAbsoluteTime(entry.created_at)}
>
{formatRelativeTime(entry.created_at)}
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -968,6 +968,29 @@ export const remoteAccessApi = {
.then((r) => r.data), .then((r) => r.data),
} }
// ─── Config History ─────────────────────────────────────────────────────────
export interface ConfigChangeEntry {
id: string
component: string
summary: string
created_at: string
diff_id: string
lines_added: number
lines_removed: number
snapshot_id: string
}
export const configHistoryApi = {
list: (tenantId: string, deviceId: string, limit = 50, offset = 0) =>
api
.get<ConfigChangeEntry[]>(
`/api/tenants/${tenantId}/devices/${deviceId}/config-history`,
{ params: { limit, offset } },
)
.then((r) => r.data),
}
// ─── VPN (WireGuard) ──────────────────────────────────────────────────────── // ─── VPN (WireGuard) ────────────────────────────────────────────────────────
export interface VpnConfigResponse { export interface VpnConfigResponse {