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),
|
.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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user