mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
417 lines
14 KiB
Svelte
417 lines
14 KiB
Svelte
<script lang="ts">
|
|
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();
|
|
|
|
try {
|
|
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>
|
|
|
|
<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 && !data}
|
|
<p class="text-destructive">{error}</p>
|
|
{:else}
|
|
<div class="bg-card border-border overflow-hidden rounded-lg border">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
{#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>
|
|
{/if}
|