feat: enhance audit log with smart diffing, conditional request caching, auto refreshing

This commit is contained in:
2026-01-29 17:34:23 -06:00
parent b58eb840f3
commit d2985f98ce
12 changed files with 1046 additions and 137 deletions
+9 -1
View File
@@ -3,7 +3,15 @@ import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import { ClipboardList, FileText, LayoutDashboard, LogOut, Settings, User, Users } from "@lucide/svelte";
import {
ClipboardList,
FileText,
LayoutDashboard,
LogOut,
Settings,
User,
Users,
} from "@lucide/svelte";
import { onMount } from "svelte";
let { children } = $props();
+392 -26
View File
@@ -1,49 +1,415 @@
<script lang="ts">
import { onMount } from "svelte";
import { client, type AuditLogResponse } from "$lib/api";
import { type AuditLogEntry, type AuditLogResponse, client } from "$lib/api";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { formatAbsoluteDate } from "$lib/date";
import { type DiffEntry, formatDiffPath, jsonDiff, tryParseJson } from "$lib/diff";
import { relativeTime } from "$lib/time";
import {
AlertCircle,
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronDown,
ChevronRight,
LoaderCircle,
} from "@lucide/svelte";
import {
type ColumnDef,
type SortingState,
type Updater,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { onDestroy, onMount } from "svelte";
import { fade, slide } from "svelte/transition";
let data = $state<AuditLogResponse | null>(null);
let error = $state<string | null>(null);
let expandedId: number | null = $state(null);
// --- Live-updating clock for relative timestamps ---
let now = $state(new Date());
let tickTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleTick() {
tickTimer = setTimeout(() => {
now = new Date();
scheduleTick();
}, 1000);
}
// --- Auto-refresh with backoff ---
// Backoff increases on errors AND on 304 (no change). Resets to min on new data.
const MIN_INTERVAL = 5_000;
const MAX_INTERVAL = 60_000;
let refreshInterval = MIN_INTERVAL;
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let refreshError = $state(false);
// Spinner stays visible for at least MIN_SPIN_MS so the animation isn't jarring.
const MIN_SPIN_MS = 700;
let spinnerVisible = $state(false);
let spinHoldTimer: ReturnType<typeof setTimeout> | undefined;
async function fetchData() {
refreshError = false;
spinnerVisible = true;
clearTimeout(spinHoldTimer);
const startedAt = performance.now();
onMount(async () => {
try {
data = await client.getAdminAuditLog();
const result = await client.getAdminAuditLog();
if (result === null) {
refreshInterval = Math.min(refreshInterval * 2, MAX_INTERVAL);
} else {
data = result;
error = null;
refreshInterval = MIN_INTERVAL;
}
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load audit log";
refreshError = true;
refreshInterval = Math.min(refreshInterval * 2, MAX_INTERVAL);
} finally {
const elapsed = performance.now() - startedAt;
const remaining = MIN_SPIN_MS - elapsed;
if (remaining > 0) {
spinHoldTimer = setTimeout(() => {
spinnerVisible = false;
}, remaining);
} else {
spinnerVisible = false;
}
scheduleRefresh();
}
}
function scheduleRefresh() {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(fetchData, refreshInterval);
}
onMount(() => {
fetchData();
scheduleTick();
});
onDestroy(() => {
clearTimeout(tickTimer);
clearTimeout(refreshTimer);
clearTimeout(spinHoldTimer);
});
// --- Change column helpers ---
interface ChangeAnalysis {
kind: "scalar" | "json-single" | "json-multi";
oldRaw: string;
newRaw: string;
diffs: DiffEntry[];
delta: number | null;
}
function analyzeChange(entry: AuditLogEntry): ChangeAnalysis {
const parsedOld = tryParseJson(entry.oldValue);
const parsedNew = tryParseJson(entry.newValue);
const isJsonOld = typeof parsedOld === "object" && parsedOld !== null;
const isJsonNew = typeof parsedNew === "object" && parsedNew !== null;
if (isJsonOld && isJsonNew) {
const diffs = jsonDiff(parsedOld, parsedNew);
const kind = diffs.length <= 1 ? "json-single" : "json-multi";
return { kind, oldRaw: entry.oldValue, newRaw: entry.newValue, diffs, delta: null };
}
let delta: number | null = null;
const numOld = Number(entry.oldValue);
const numNew = Number(entry.newValue);
if (
!Number.isNaN(numOld) &&
!Number.isNaN(numNew) &&
entry.oldValue !== "" &&
entry.newValue !== ""
) {
delta = numNew - numOld;
}
return { kind: "scalar", oldRaw: entry.oldValue, newRaw: entry.newValue, diffs: [], delta };
}
function formatDelta(delta: number): string {
return delta >= 0 ? `+${delta}` : `${delta}`;
}
function stringify(val: unknown): string {
if (val === undefined) return "∅";
if (typeof val === "string") return val;
return JSON.stringify(val);
}
function toggleExpanded(id: number) {
expandedId = expandedId === id ? null : id;
}
function formatCourse(entry: AuditLogEntry): string {
if (entry.subject && entry.courseNumber) {
return `${entry.subject} ${entry.courseNumber}`;
}
return `#${entry.courseId}`;
}
function formatCourseTooltip(entry: AuditLogEntry): string {
const parts: string[] = [];
if (entry.courseTitle) parts.push(entry.courseTitle);
if (entry.crn) parts.push(`CRN ${entry.crn}`);
parts.push(`ID ${entry.courseId}`);
return parts.join("\n");
}
// --- TanStack Table ---
let sorting: SortingState = $state([{ id: "time", desc: true }]);
function handleSortingChange(updater: Updater<SortingState>) {
sorting = typeof updater === "function" ? updater(sorting) : updater;
}
const columns: ColumnDef<AuditLogEntry, unknown>[] = [
{
id: "time",
accessorKey: "timestamp",
header: "Time",
enableSorting: true,
},
{
id: "course",
accessorKey: "courseId",
header: "Course",
enableSorting: false,
},
{
id: "field",
accessorKey: "fieldChanged",
header: "Field",
enableSorting: true,
},
{
id: "change",
accessorFn: () => "",
header: "Change",
enableSorting: false,
},
];
const table = createSvelteTable({
get data() {
return data?.entries ?? [];
},
getRowId: (row) => String(row.id),
columns,
state: {
get sorting() {
return sorting;
},
},
onSortingChange: handleSortingChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel<AuditLogEntry>(),
enableSortingRemoval: true,
});
const skeletonWidths: Record<string, string> = {
time: "w-24",
course: "w-20",
field: "w-20",
change: "w-40",
};
const columnCount = columns.length;
</script>
<h1 class="mb-4 text-lg font-semibold text-foreground">Audit Log</h1>
<div class="mb-4 flex items-center gap-2">
<h1 class="text-lg font-semibold text-foreground">Audit Log</h1>
{#if spinnerVisible}
<span in:fade={{ duration: 150 }} out:fade={{ duration: 200 }}>
<LoaderCircle class="size-4 animate-spin text-muted-foreground" />
</span>
{:else if refreshError}
<span in:fade={{ duration: 150 }} out:fade={{ duration: 200 }}>
<SimpleTooltip text={error ?? "Refresh failed"} side="right" passthrough>
<AlertCircle class="size-4 text-destructive" />
</SimpleTooltip>
</span>
{/if}
</div>
{#if error}
{#if error && !data}
<p class="text-destructive">{error}</p>
{:else if !data}
<p class="text-muted-foreground">Loading...</p>
{:else if data.entries.length === 0}
<p class="text-muted-foreground">No audit log entries found.</p>
{:else}
<div class="bg-card border-border overflow-hidden rounded-lg border">
<table class="w-full text-sm">
<thead>
<tr class="border-border border-b">
<th class="px-4 py-3 text-left font-medium">Time</th>
<th class="px-4 py-3 text-left font-medium">Course ID</th>
<th class="px-4 py-3 text-left font-medium">Field</th>
<th class="px-4 py-3 text-left font-medium">Old Value</th>
<th class="px-4 py-3 text-left font-medium">New Value</th>
</tr>
</thead>
<tbody>
{#each data.entries as entry}
<tr class="border-border border-b last:border-b-0">
<td class="px-4 py-3">{new Date(entry.timestamp).toLocaleString()}</td>
<td class="px-4 py-3">{entry.courseId}</td>
<td class="px-4 py-3 font-mono text-xs">{entry.fieldChanged}</td>
<td class="px-4 py-3">{entry.oldValue}</td>
<td class="px-4 py-3">{entry.newValue}</td>
{#each table.getHeaderGroups() as headerGroup}
<tr class="border-b border-border text-left text-muted-foreground">
{#each headerGroup.headers as header}
<th
class="px-4 py-3 font-medium"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span class="inline-flex items-center gap-1">
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown class="size-3.5" />
{:else}
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#if !data}
<!-- Skeleton loading -->
{#each Array(20) as _}
<tr class="border-b border-border">
{#each columns as col}
<td class="px-4 py-3">
<div
class="h-4 rounded bg-muted animate-pulse {skeletonWidths[col.id ?? ''] ?? 'w-20'}"
></div>
</td>
{/each}
</tr>
{/each}
{:else if data.entries.length === 0}
<tr>
<td colspan={columnCount} class="px-4 py-12 text-center text-muted-foreground">
No audit log entries found.
</td>
</tr>
{:else}
{#each table.getRowModel().rows as row (row.id)}
{@const entry = row.original}
{@const change = analyzeChange(entry)}
{@const isExpanded = expandedId === entry.id}
{@const clickable = change.kind === "json-multi"}
<tr
class="border-b border-border transition-colors last:border-b-0
{clickable ? 'cursor-pointer hover:bg-muted/50' : ''}
{isExpanded ? 'bg-muted/30' : ''}"
onclick={clickable ? () => toggleExpanded(entry.id) : undefined}
>
{#each row.getVisibleCells() as cell (cell.id)}
{@const colId = cell.column.id}
{#if colId === "time"}
{@const rel = relativeTime(new Date(entry.timestamp), now)}
<td class="px-4 py-3 whitespace-nowrap">
<SimpleTooltip text={formatAbsoluteDate(entry.timestamp)} side="right" passthrough>
<span class="font-mono text-xs text-muted-foreground">{rel.text === "now" ? "just now" : `${rel.text} ago`}</span>
</SimpleTooltip>
</td>
{:else if colId === "course"}
<td class="px-4 py-3 whitespace-nowrap">
<SimpleTooltip text={formatCourseTooltip(entry)} side="right" passthrough>
<span class="font-mono text-xs text-foreground">{formatCourse(entry)}</span>
</SimpleTooltip>
</td>
{:else if colId === "field"}
<td class="px-4 py-3">
<span
class="inline-block rounded-full bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground"
>
{entry.fieldChanged}
</span>
</td>
{:else if colId === "change"}
<td class="px-4 py-3">
{#if change.kind === "scalar"}
<span class="inline-flex items-center gap-1.5 text-sm">
{#if change.delta !== null}
<span class="text-foreground">{formatDelta(change.delta)}<span class="text-muted-foreground/60">,</span></span>
{/if}
<span class="text-red-400">{change.oldRaw}</span>
<span class="text-muted-foreground/60"></span>
<span class="text-green-600 dark:text-green-400">{change.newRaw}</span>
</span>
{:else if change.kind === "json-single"}
{#if change.diffs.length === 1}
{@const d = change.diffs[0]}
<span class="font-mono text-xs">
<span class="text-muted-foreground">{formatDiffPath(d.path)}:</span>
{" "}
<span class="text-red-400">{stringify(d.oldVal)}</span>
<span class="text-muted-foreground"></span>
<span class="text-green-600 dark:text-green-400">{stringify(d.newVal)}</span>
</span>
{:else}
<span class="text-muted-foreground text-xs italic">No changes</span>
{/if}
{:else if change.kind === "json-multi"}
<span class="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
{#if isExpanded}
<ChevronDown class="size-3.5 shrink-0" />
{:else}
<ChevronRight class="size-3.5 shrink-0" />
{/if}
<span class="underline decoration-dotted underline-offset-2">
{change.diffs.length} fields changed
</span>
</span>
{/if}
</td>
{/if}
{/each}
</tr>
<!-- Expandable detail row for multi-path JSON diffs -->
{#if isExpanded && change.kind === "json-multi"}
<tr class="border-b border-border last:border-b-0">
<td colspan={columnCount} class="p-0">
<div transition:slide={{ duration: 200 }}>
<div class="bg-muted/20 px-4 py-3">
<div class="space-y-1.5">
{#each change.diffs as d}
<div class="font-mono text-xs">
<span class="text-muted-foreground">{formatDiffPath(d.path)}:</span>
{" "}
<span class="text-red-400">{stringify(d.oldVal)}</span>
<span class="text-muted-foreground"></span>
<span class="text-green-600 dark:text-green-400">{stringify(d.newVal)}</span>
</div>
{/each}
</div>
</div>
</div>
</td>
</tr>
{/if}
{/each}
{/if}
</tbody>
</table>
</div>
+239 -25
View File
@@ -1,9 +1,21 @@
<script lang="ts">
import { type ScrapeJob, type ScrapeJobsResponse, client } from "$lib/api";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { formatAbsoluteDate, formatRelativeDate } from "$lib/date";
import { ArrowDown, ArrowUp, ArrowUpDown } from "@lucide/svelte";
import {
type ColumnDef,
type SortingState,
type Updater,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { onMount } from "svelte";
import { client, type ScrapeJobsResponse } from "$lib/api";
let data = $state<ScrapeJobsResponse | null>(null);
let error = $state<string | null>(null);
let sorting: SortingState = $state([]);
onMount(async () => {
try {
@@ -12,41 +24,243 @@ onMount(async () => {
error = e instanceof Error ? e.message : "Failed to load scrape jobs";
}
});
function handleSortingChange(updater: Updater<SortingState>) {
sorting = typeof updater === "function" ? updater(sorting) : updater;
}
function priorityColor(priority: string): string {
const p = priority.toLowerCase();
if (p === "urgent") return "text-red-500";
if (p === "low") return "text-muted-foreground";
return "text-foreground";
}
function retryColor(retryCount: number, maxRetries: number): string {
if (retryCount >= maxRetries && maxRetries > 0) return "text-red-500";
if (retryCount > 0) return "text-amber-500";
return "text-muted-foreground";
}
const columns: ColumnDef<ScrapeJob, unknown>[] = [
{
id: "id",
accessorKey: "id",
header: "ID",
enableSorting: false,
},
{
id: "targetType",
accessorKey: "targetType",
header: "Type",
enableSorting: false,
},
{
id: "priority",
accessorKey: "priority",
header: "Priority",
enableSorting: true,
sortingFn: (rowA, rowB) => {
const order: Record<string, number> = { urgent: 0, high: 1, normal: 2, low: 3 };
const a = order[String(rowA.original.priority).toLowerCase()] ?? 2;
const b = order[String(rowB.original.priority).toLowerCase()] ?? 2;
return a - b;
},
},
{
id: "executeAt",
accessorKey: "executeAt",
header: "Execute At",
enableSorting: true,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: "Created At",
enableSorting: false,
},
{
id: "retries",
accessorFn: (row) => row.retryCount,
header: "Retries",
enableSorting: false,
},
{
id: "status",
accessorFn: (row) => (row.lockedAt ? "Locked" : "Pending"),
header: "Status",
enableSorting: true,
},
];
const table = createSvelteTable({
get data() {
return data?.jobs ?? [];
},
getRowId: (row) => String(row.id),
columns,
state: {
get sorting() {
return sorting;
},
},
onSortingChange: handleSortingChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSortingRemoval: true,
});
const skeletonWidths: Record<string, string> = {
id: "w-8",
targetType: "w-16",
priority: "w-16",
executeAt: "w-28",
createdAt: "w-28",
retries: "w-12",
status: "w-20",
};
</script>
<h1 class="mb-4 text-lg font-semibold text-foreground">Scrape Jobs</h1>
{#if error}
<p class="text-destructive">{error}</p>
{:else if !data}
<p class="text-muted-foreground">Loading...</p>
{:else if data.jobs.length === 0}
<p class="text-muted-foreground">No scrape jobs found.</p>
{:else}
<div class="bg-card border-border overflow-hidden rounded-lg border">
<table class="w-full text-sm">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-border border-b">
<th class="px-4 py-3 text-left font-medium">ID</th>
<th class="px-4 py-3 text-left font-medium">Type</th>
<th class="px-4 py-3 text-left font-medium">Priority</th>
<th class="px-4 py-3 text-left font-medium">Execute At</th>
<th class="px-4 py-3 text-left font-medium">Retries</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
</tr>
</thead>
<tbody>
{#each data.jobs as job}
<tr class="border-border border-b last:border-b-0">
<td class="px-4 py-3">{job.id}</td>
<td class="px-4 py-3">{job.targetType}</td>
<td class="px-4 py-3">{job.priority}</td>
<td class="px-4 py-3">{new Date(job.executeAt).toLocaleString()}</td>
<td class="px-4 py-3">{job.retryCount}/{job.maxRetries}</td>
<td class="px-4 py-3">{job.lockedAt ? "Locked" : "Pending"}</td>
{#each table.getHeaderGroups() as headerGroup}
<tr class="border-b border-border text-left text-muted-foreground">
{#each headerGroup.headers as header}
<th
class="px-4 py-3 font-medium"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span class="inline-flex items-center gap-1">
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown class="size-3.5" />
{:else}
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</th>
{/each}
</tr>
{/each}
</tbody>
</thead>
{#if !data}
<tbody>
{#each Array(5) as _}
<tr class="border-b border-border">
{#each columns as col}
<td class="px-4 py-3">
<div
class="h-4 rounded bg-muted animate-pulse {skeletonWidths[col.id ?? ''] ?? 'w-20'}"
></div>
</td>
{/each}
</tr>
{/each}
</tbody>
{:else if data.jobs.length === 0}
<tbody>
<tr>
<td colspan={columns.length} class="py-12 text-center text-muted-foreground">
No scrape jobs found.
</td>
</tr>
</tbody>
{:else}
<tbody>
{#each table.getRowModel().rows as row}
{@const job = row.original}
<tr class="border-b border-border last:border-b-0 hover:bg-muted/50 transition-colors">
{#each row.getVisibleCells() as cell (cell.id)}
{@const colId = cell.column.id}
{#if colId === "id"}
<td class="px-4 py-3 tabular-nums text-muted-foreground">{job.id}</td>
{:else if colId === "targetType"}
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-md bg-muted/60 px-2 py-0.5 font-mono text-xs text-muted-foreground"
>
{job.targetType}
</span>
</td>
{:else if colId === "priority"}
<td class="px-4 py-3">
<span class="font-medium capitalize {priorityColor(job.priority)}">
{job.priority}
</span>
</td>
{:else if colId === "executeAt"}
<td class="px-4 py-3">
<SimpleTooltip text={formatAbsoluteDate(job.executeAt)} passthrough>
<span class="text-muted-foreground">
{formatRelativeDate(job.executeAt)}
</span>
</SimpleTooltip>
</td>
{:else if colId === "createdAt"}
<td class="px-4 py-3">
<SimpleTooltip text={formatAbsoluteDate(job.createdAt)} passthrough>
<span class="text-muted-foreground">
{formatRelativeDate(job.createdAt)}
</span>
</SimpleTooltip>
</td>
{:else if colId === "retries"}
<td class="px-4 py-3">
<span class="tabular-nums {retryColor(job.retryCount, job.maxRetries)}">
{job.retryCount}/{job.maxRetries}
</span>
</td>
{:else if colId === "status"}
<td class="px-4 py-3">
{#if job.lockedAt}
<SimpleTooltip
text="Locked since {formatAbsoluteDate(job.lockedAt)}"
passthrough
>
<span class="inline-flex items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full bg-amber-500"></span>
<span class="text-amber-500">Locked</span>
</span>
</SimpleTooltip>
{:else}
<span class="inline-flex items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full bg-green-500"></span>
<span class="text-green-500">Pending</span>
</span>
{/if}
</td>
{/if}
{/each}
</tr>
{/each}
</tbody>
{/if}
</table>
</div>
{/if}