mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 00:23:31 -06:00
feat: enhance audit log with smart diffing, conditional request caching, auto refreshing
This commit is contained in:
+68
-6
@@ -3,8 +3,9 @@
|
||||
//! All endpoints require the `AdminUser` extractor, returning 401/403 as needed.
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -169,13 +170,49 @@ pub async fn list_scrape_jobs(
|
||||
Ok(Json(json!({ "jobs": jobs })))
|
||||
}
|
||||
|
||||
/// Row returned by the audit-log query (audit + joined course fields).
|
||||
#[derive(sqlx::FromRow, Debug)]
|
||||
struct AuditRow {
|
||||
id: i32,
|
||||
course_id: i32,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
field_changed: String,
|
||||
old_value: String,
|
||||
new_value: String,
|
||||
// Joined from courses table (nullable in case the course was deleted)
|
||||
subject: Option<String>,
|
||||
course_number: Option<String>,
|
||||
crn: Option<String>,
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
/// Format a `DateTime<Utc>` as an HTTP-date (RFC 2822) for Last-Modified headers.
|
||||
fn to_http_date(dt: &DateTime<Utc>) -> String {
|
||||
dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
|
||||
}
|
||||
|
||||
/// Parse an `If-Modified-Since` header value into a `DateTime<Utc>`.
|
||||
fn parse_if_modified_since(headers: &HeaderMap) -> Option<DateTime<Utc>> {
|
||||
let val = headers.get(header::IF_MODIFIED_SINCE)?.to_str().ok()?;
|
||||
DateTime::parse_from_rfc2822(val)
|
||||
.ok()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
/// `GET /api/admin/audit-log` — List recent audit entries.
|
||||
///
|
||||
/// Supports `If-Modified-Since`: returns 304 when the newest entry hasn't changed.
|
||||
pub async fn list_audit_log(
|
||||
AdminUser(_user): AdminUser,
|
||||
headers: HeaderMap,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let rows = sqlx::query_as::<_, crate::data::models::CourseAudit>(
|
||||
"SELECT * FROM course_audits ORDER BY timestamp DESC LIMIT 200",
|
||||
) -> Result<Response, (StatusCode, Json<Value>)> {
|
||||
let rows = sqlx::query_as::<_, AuditRow>(
|
||||
"SELECT a.id, a.course_id, a.timestamp, a.field_changed, a.old_value, a.new_value, \
|
||||
c.subject, c.course_number, c.crn, c.title \
|
||||
FROM course_audits a \
|
||||
LEFT JOIN courses c ON c.id = a.course_id \
|
||||
ORDER BY a.timestamp DESC LIMIT 200",
|
||||
)
|
||||
.fetch_all(&state.db_pool)
|
||||
.await
|
||||
@@ -187,6 +224,21 @@ pub async fn list_audit_log(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Determine the latest timestamp across all rows (query is DESC so first row is newest)
|
||||
let latest = rows.first().map(|r| r.timestamp);
|
||||
|
||||
// If the client sent If-Modified-Since and our data hasn't changed, return 304
|
||||
if let (Some(since), Some(latest_ts)) = (parse_if_modified_since(&headers), latest) {
|
||||
// Truncate to seconds for comparison (HTTP dates have second precision)
|
||||
if latest_ts.timestamp() <= since.timestamp() {
|
||||
let mut resp = StatusCode::NOT_MODIFIED.into_response();
|
||||
if let Ok(val) = to_http_date(&latest_ts).parse() {
|
||||
resp.headers_mut().insert(header::LAST_MODIFIED, val);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
||||
|
||||
let entries: Vec<Value> = rows
|
||||
.iter()
|
||||
.map(|a| {
|
||||
@@ -197,9 +249,19 @@ pub async fn list_audit_log(
|
||||
"fieldChanged": a.field_changed,
|
||||
"oldValue": a.old_value,
|
||||
"newValue": a.new_value,
|
||||
"subject": a.subject,
|
||||
"courseNumber": a.course_number,
|
||||
"crn": a.crn,
|
||||
"courseTitle": a.title,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "entries": entries })))
|
||||
let mut resp = Json(json!({ "entries": entries })).into_response();
|
||||
if let Some(latest_ts) = latest
|
||||
&& let Ok(val) = to_http_date(&latest_ts).parse()
|
||||
{
|
||||
resp.headers_mut().insert(header::LAST_MODIFIED, val);
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "banner-web",
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"overlayscrollbars": "^2.14.0",
|
||||
"overlayscrollbars-svelte": "^0.5.5",
|
||||
},
|
||||
@@ -288,6 +289,8 @@
|
||||
|
||||
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"overlayscrollbars": "^2.14.0",
|
||||
"overlayscrollbars-svelte": "^0.5.5"
|
||||
}
|
||||
|
||||
+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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user