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:
97
frontend/src/components/config/ConfigHistorySection.tsx
Normal file
97
frontend/src/components/config/ConfigHistorySection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -968,6 +968,29 @@ export const remoteAccessApi = {
|
||||
.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) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface VpnConfigResponse {
|
||||
|
||||
Reference in New Issue
Block a user