mirror of
https://github.com/Xevion/banner.git
synced 2026-02-01 12:23:41 -06:00
feat: enhance audit log with smart diffing, conditional request caching, auto refreshing
This commit is contained in:
+35
-2
@@ -66,6 +66,10 @@ export interface AuditLogEntry {
|
||||
fieldChanged: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
subject: string | null;
|
||||
courseNumber: string | null;
|
||||
crn: string | null;
|
||||
courseTitle: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
@@ -181,10 +185,39 @@ export class BannerApiClient {
|
||||
return this.request<ScrapeJobsResponse>("/admin/scrape-jobs");
|
||||
}
|
||||
|
||||
async getAdminAuditLog(): Promise<AuditLogResponse> {
|
||||
return this.request<AuditLogResponse>("/admin/audit-log");
|
||||
/**
|
||||
* Fetch the audit log with conditional request support.
|
||||
*
|
||||
* Returns `null` when the server responds 304 (data unchanged).
|
||||
* Stores and sends `Last-Modified` / `If-Modified-Since` automatically.
|
||||
*/
|
||||
async getAdminAuditLog(): Promise<AuditLogResponse | null> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this._auditLastModified) {
|
||||
headers["If-Modified-Since"] = this._auditLastModified;
|
||||
}
|
||||
|
||||
const response = await this.fetchFn(`${this.baseUrl}/admin/audit-log`, { headers });
|
||||
|
||||
if (response.status === 304) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const lastMod = response.headers.get("Last-Modified");
|
||||
if (lastMod) {
|
||||
this._auditLastModified = lastMod;
|
||||
}
|
||||
|
||||
return (await response.json()) as AuditLogResponse;
|
||||
}
|
||||
|
||||
/** Stored `Last-Modified` value for audit log conditional requests. */
|
||||
private _auditLastModified: string | null = null;
|
||||
|
||||
async getMetrics(params?: MetricsParams): Promise<MetricsResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.course_id !== undefined) query.set("course_id", String(params.course_id));
|
||||
|
||||
@@ -1,61 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
import { relativeTime } from "$lib/time";
|
||||
import { onMount } from "svelte";
|
||||
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
import { relativeTime } from "$lib/time";
|
||||
|
||||
export interface SearchMeta {
|
||||
totalCount: number;
|
||||
durationMs: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
export interface SearchMeta {
|
||||
totalCount: number;
|
||||
durationMs: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { meta }: { meta: SearchMeta | null } = $props();
|
||||
let { meta }: { meta: SearchMeta | null } = $props();
|
||||
|
||||
let now = $state(new Date());
|
||||
let now = $state(new Date());
|
||||
|
||||
let formattedTime = $derived(
|
||||
meta
|
||||
? meta.timestamp.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
: "",
|
||||
);
|
||||
let formattedTime = $derived(
|
||||
meta
|
||||
? meta.timestamp.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
: ""
|
||||
);
|
||||
|
||||
let relativeTimeResult = $derived(
|
||||
meta ? relativeTime(meta.timestamp, now) : null,
|
||||
);
|
||||
let relativeTimeText = $derived(relativeTimeResult?.text ?? "");
|
||||
let relativeTimeResult = $derived(meta ? relativeTime(meta.timestamp, now) : null);
|
||||
let relativeTimeText = $derived(relativeTimeResult?.text ?? "");
|
||||
|
||||
let countLabel = $derived(meta ? meta.totalCount.toLocaleString() : "");
|
||||
let resultNoun = $derived(
|
||||
meta ? (meta.totalCount !== 1 ? "results" : "result") : "",
|
||||
);
|
||||
let durationLabel = $derived(
|
||||
meta ? `${Math.round(meta.durationMs)}ms` : "",
|
||||
);
|
||||
let countLabel = $derived(meta ? meta.totalCount.toLocaleString() : "");
|
||||
let resultNoun = $derived(meta ? (meta.totalCount !== 1 ? "results" : "result") : "");
|
||||
let durationLabel = $derived(meta ? `${Math.round(meta.durationMs)}ms` : "");
|
||||
|
||||
let tooltipText = $derived(
|
||||
meta ? `${relativeTimeText} · ${formattedTime}` : "",
|
||||
);
|
||||
let tooltipText = $derived(meta ? `${relativeTimeText} · ${formattedTime}` : "");
|
||||
|
||||
onMount(() => {
|
||||
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
onMount(() => {
|
||||
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleNowTick() {
|
||||
const delay = relativeTimeResult?.nextUpdateMs ?? 1000;
|
||||
nowTimeoutId = setTimeout(() => {
|
||||
now = new Date();
|
||||
scheduleNowTick();
|
||||
}, delay);
|
||||
}
|
||||
scheduleNowTick();
|
||||
function scheduleNowTick() {
|
||||
const delay = relativeTimeResult?.nextUpdateMs ?? 1000;
|
||||
nowTimeoutId = setTimeout(() => {
|
||||
now = new Date();
|
||||
scheduleNowTick();
|
||||
}, delay);
|
||||
}
|
||||
scheduleNowTick();
|
||||
|
||||
return () => {
|
||||
if (nowTimeoutId) clearTimeout(nowTimeoutId);
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
if (nowTimeoutId) clearTimeout(nowTimeoutId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<SimpleTooltip
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
|
||||
/** Returns a relative time string like "3 minutes ago" or "in 2 hours". */
|
||||
export function formatRelativeDate(date: string | Date): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return formatDistanceToNow(d, { addSuffix: true });
|
||||
}
|
||||
|
||||
/** Returns a full absolute datetime string for tooltip display, e.g. "Jan 29, 2026, 3:45:12 PM". */
|
||||
export function formatAbsoluteDate(date: string | Date): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return format(d, "MMM d, yyyy, h:mm:ss a");
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatDiffPath, jsonDiff, tryParseJson } from "./diff";
|
||||
|
||||
describe("jsonDiff", () => {
|
||||
describe("scalars", () => {
|
||||
it("returns empty array for identical primitives", () => {
|
||||
expect(jsonDiff(42, 42)).toEqual([]);
|
||||
expect(jsonDiff("hello", "hello")).toEqual([]);
|
||||
expect(jsonDiff(true, true)).toEqual([]);
|
||||
expect(jsonDiff(null, null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single entry for different primitives", () => {
|
||||
expect(jsonDiff("Open", "Closed")).toEqual([{ path: "", oldVal: "Open", newVal: "Closed" }]);
|
||||
expect(jsonDiff(25, 30)).toEqual([{ path: "", oldVal: 25, newVal: 30 }]);
|
||||
expect(jsonDiff(true, false)).toEqual([{ path: "", oldVal: true, newVal: false }]);
|
||||
});
|
||||
|
||||
it("returns entry when types differ", () => {
|
||||
expect(jsonDiff(1, "1")).toEqual([{ path: "", oldVal: 1, newVal: "1" }]);
|
||||
expect(jsonDiff(null, 0)).toEqual([{ path: "", oldVal: null, newVal: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objects", () => {
|
||||
it("detects changed key", () => {
|
||||
expect(jsonDiff({ a: 1 }, { a: 2 })).toEqual([{ path: ".a", oldVal: 1, newVal: 2 }]);
|
||||
});
|
||||
|
||||
it("detects added key", () => {
|
||||
expect(jsonDiff({}, { a: 1 })).toEqual([{ path: ".a", oldVal: undefined, newVal: 1 }]);
|
||||
});
|
||||
|
||||
it("detects removed key", () => {
|
||||
expect(jsonDiff({ a: 1 }, {})).toEqual([{ path: ".a", oldVal: 1, newVal: undefined }]);
|
||||
});
|
||||
|
||||
it("handles deeply nested changes", () => {
|
||||
const oldVal = { a: { b: { c: 1 } } };
|
||||
const newVal = { a: { b: { c: 2 } } };
|
||||
expect(jsonDiff(oldVal, newVal)).toEqual([{ path: ".a.b.c", oldVal: 1, newVal: 2 }]);
|
||||
});
|
||||
|
||||
it("returns empty for identical objects", () => {
|
||||
expect(jsonDiff({ a: 1, b: "x" }, { a: 1, b: "x" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrays", () => {
|
||||
it("detects changed element", () => {
|
||||
expect(jsonDiff([1, 2, 3], [1, 99, 3])).toEqual([{ path: "[1]", oldVal: 2, newVal: 99 }]);
|
||||
});
|
||||
|
||||
it("detects added element (new array longer)", () => {
|
||||
expect(jsonDiff([1], [1, 2])).toEqual([{ path: "[1]", oldVal: undefined, newVal: 2 }]);
|
||||
});
|
||||
|
||||
it("detects removed element (new array shorter)", () => {
|
||||
expect(jsonDiff([1, 2], [1])).toEqual([{ path: "[1]", oldVal: 2, newVal: undefined }]);
|
||||
});
|
||||
|
||||
it("returns empty for identical arrays", () => {
|
||||
expect(jsonDiff([1, 2, 3], [1, 2, 3])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed nesting", () => {
|
||||
it("handles array of objects", () => {
|
||||
const oldVal = [{ name: "Alice" }, { name: "Bob" }];
|
||||
const newVal = [{ name: "Alice" }, { name: "Charlie" }];
|
||||
expect(jsonDiff(oldVal, newVal)).toEqual([
|
||||
{ path: "[1].name", oldVal: "Bob", newVal: "Charlie" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles object with nested arrays", () => {
|
||||
const oldVal = { meetingTimes: [{ beginTime: "0900" }] };
|
||||
const newVal = { meetingTimes: [{ beginTime: "1000" }] };
|
||||
expect(jsonDiff(oldVal, newVal)).toEqual([
|
||||
{ path: ".meetingTimes[0].beginTime", oldVal: "0900", newVal: "1000" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles type change from object to array", () => {
|
||||
expect(jsonDiff({ a: 1 }, [1])).toEqual([{ path: "", oldVal: { a: 1 }, newVal: [1] }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryParseJson", () => {
|
||||
it("parses valid JSON object", () => {
|
||||
expect(tryParseJson('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("parses valid JSON array", () => {
|
||||
expect(tryParseJson("[1,2,3]")).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("parses plain string numbers", () => {
|
||||
expect(tryParseJson("42")).toBe(42);
|
||||
expect(tryParseJson("3.14")).toBe(3.14);
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
expect(tryParseJson("not json")).toBeNull();
|
||||
expect(tryParseJson("{broken")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses boolean and null literals", () => {
|
||||
expect(tryParseJson("true")).toBe(true);
|
||||
expect(tryParseJson("null")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDiffPath", () => {
|
||||
it("strips leading dot", () => {
|
||||
expect(formatDiffPath(".a.b.c")).toBe("a.b.c");
|
||||
});
|
||||
|
||||
it("returns (root) for empty path", () => {
|
||||
expect(formatDiffPath("")).toBe("(root)");
|
||||
});
|
||||
|
||||
it("preserves bracket notation", () => {
|
||||
expect(formatDiffPath("[0].name")).toBe("[0].name");
|
||||
});
|
||||
|
||||
it("handles mixed paths", () => {
|
||||
expect(formatDiffPath(".meetingTimes[0].beginTime")).toBe("meetingTimes[0].beginTime");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface DiffEntry {
|
||||
path: string;
|
||||
oldVal: unknown;
|
||||
newVal: unknown;
|
||||
}
|
||||
|
||||
function isObject(val: unknown): val is Record<string, unknown> {
|
||||
return val !== null && typeof val === "object" && !Array.isArray(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively compares two JSON-compatible values and returns a list of
|
||||
* structural differences with dot-notation paths.
|
||||
*/
|
||||
export function jsonDiff(oldVal: unknown, newVal: unknown): DiffEntry[] {
|
||||
return diffRecurse("", oldVal, newVal);
|
||||
}
|
||||
|
||||
function diffRecurse(path: string, oldVal: unknown, newVal: unknown): DiffEntry[] {
|
||||
// Both arrays: compare by index up to max length
|
||||
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
|
||||
const entries: DiffEntry[] = [];
|
||||
const maxLen = Math.max(oldVal.length, newVal.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const childPath = `${path}[${i}]`;
|
||||
const inOld = i < oldVal.length;
|
||||
const inNew = i < newVal.length;
|
||||
if (inOld && inNew) {
|
||||
entries.push(...diffRecurse(childPath, oldVal[i], newVal[i]));
|
||||
} else if (inNew) {
|
||||
entries.push({ path: childPath, oldVal: undefined, newVal: newVal[i] });
|
||||
} else {
|
||||
entries.push({ path: childPath, oldVal: oldVal[i], newVal: undefined });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Both objects: iterate union of keys
|
||||
if (isObject(oldVal) && isObject(newVal)) {
|
||||
const entries: DiffEntry[] = [];
|
||||
const allKeys = new Set([...Object.keys(oldVal), ...Object.keys(newVal)]);
|
||||
for (const key of allKeys) {
|
||||
const childPath = `${path}.${key}`;
|
||||
const inOld = key in oldVal;
|
||||
const inNew = key in newVal;
|
||||
if (inOld && inNew) {
|
||||
entries.push(...diffRecurse(childPath, oldVal[key], newVal[key]));
|
||||
} else if (inNew) {
|
||||
entries.push({ path: childPath, oldVal: undefined, newVal: newVal[key] });
|
||||
} else {
|
||||
entries.push({ path: childPath, oldVal: oldVal[key], newVal: undefined });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Leaf comparison (primitives, or type mismatch between object/array/primitive)
|
||||
if (oldVal !== newVal) {
|
||||
return [{ path, oldVal, newVal }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up a diff path for display. Strips the leading dot produced by
|
||||
* object-key paths, and returns "(root)" for the empty root path.
|
||||
*/
|
||||
export function formatDiffPath(path: string): string {
|
||||
if (path === "") return "(root)";
|
||||
if (path.startsWith(".")) return path.slice(1);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse a string as JSON. Returns the parsed value on success,
|
||||
* or null if parsing fails. Used by the audit log to detect JSON values.
|
||||
*/
|
||||
export function tryParseJson(value: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+30
-30
@@ -1,69 +1,69 @@
|
||||
/**
|
||||
* Relative time formatting with adaptive refresh intervals.
|
||||
*
|
||||
* The key insight: a timestamp showing "3 seconds ago" needs to update every second,
|
||||
* but "2 hours ago" only needs to update every minute. This module provides both
|
||||
* The key insight: a timestamp showing "3s" needs to update every second,
|
||||
* but "2h 15m" only needs to update every minute. This module provides both
|
||||
* the formatted string and the optimal interval until the next meaningful change.
|
||||
*/
|
||||
|
||||
interface RelativeTimeResult {
|
||||
/** The human-readable relative time string (e.g. "3 seconds ago") */
|
||||
/** Compact relative time string (e.g. "9m 35s", "1h 23m", "3d") */
|
||||
text: string;
|
||||
/** Milliseconds until the displayed text would change */
|
||||
nextUpdateMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a relative time string and the interval until it next changes.
|
||||
* Compute a compact relative time string and the interval until it next changes.
|
||||
*
|
||||
* Granularity tiers:
|
||||
* - < 60s: per-second ("1 second ago", "45 seconds ago")
|
||||
* - < 60m: per-minute ("1 minute ago", "12 minutes ago")
|
||||
* - < 24h: per-hour ("1 hour ago", "5 hours ago")
|
||||
* - >= 24h: per-day ("1 day ago", "3 days ago")
|
||||
* Format tiers:
|
||||
* - < 60s: seconds only ("45s")
|
||||
* - < 1h: minutes + seconds ("9m 35s")
|
||||
* - < 24h: hours + minutes ("1h 23m")
|
||||
* - >= 24h: days only ("3d")
|
||||
*/
|
||||
export function relativeTime(date: Date, ref: Date): RelativeTimeResult {
|
||||
const diffMs = ref.getTime() - date.getTime();
|
||||
const seconds = Math.round(diffMs / 1000);
|
||||
const totalSeconds = Math.floor(diffMs / 1000);
|
||||
|
||||
if (seconds < 1) {
|
||||
return { text: "just now", nextUpdateMs: 1000 - (diffMs % 1000) || 1000 };
|
||||
if (totalSeconds < 1) {
|
||||
return { text: "now", nextUpdateMs: 1000 - (diffMs % 1000) || 1000 };
|
||||
}
|
||||
|
||||
if (seconds < 60) {
|
||||
if (totalSeconds < 60) {
|
||||
const remainder = 1000 - (diffMs % 1000);
|
||||
return {
|
||||
text: seconds === 1 ? "1 second ago" : `${seconds} seconds ago`,
|
||||
text: `${totalSeconds}s`,
|
||||
nextUpdateMs: remainder || 1000,
|
||||
};
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
// Update when the next minute boundary is crossed
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
const secs = totalSeconds % 60;
|
||||
const remainder = 1000 - (diffMs % 1000);
|
||||
return {
|
||||
text: `${totalMinutes}m ${secs}s`,
|
||||
nextUpdateMs: remainder || 1000,
|
||||
};
|
||||
}
|
||||
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
if (totalHours < 24) {
|
||||
const mins = totalMinutes % 60;
|
||||
const msIntoCurrentMinute = diffMs % 60_000;
|
||||
const msUntilNextMinute = 60_000 - msIntoCurrentMinute;
|
||||
return {
|
||||
text: minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`,
|
||||
text: `${totalHours}h ${mins}m`,
|
||||
nextUpdateMs: msUntilNextMinute || 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
const msIntoCurrentHour = diffMs % 3_600_000;
|
||||
const msUntilNextHour = 3_600_000 - msIntoCurrentHour;
|
||||
return {
|
||||
text: hours === 1 ? "1 hour ago" : `${hours} hours ago`,
|
||||
nextUpdateMs: msUntilNextHour || 3_600_000,
|
||||
};
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
const days = Math.floor(totalHours / 24);
|
||||
const msIntoCurrentDay = diffMs % 86_400_000;
|
||||
const msUntilNextDay = 86_400_000 - msIntoCurrentDay;
|
||||
return {
|
||||
text: days === 1 ? "1 day ago" : `${days} days ago`,
|
||||
text: `${days}d`,
|
||||
nextUpdateMs: msUntilNextDay || 86_400_000,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user