diff --git a/src/web/admin.rs b/src/web/admin.rs index b7e8db8..82068e0 100644 --- a/src/web/admin.rs +++ b/src/web/admin.rs @@ -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, + field_changed: String, + old_value: String, + new_value: String, + // Joined from courses table (nullable in case the course was deleted) + subject: Option, + course_number: Option, + crn: Option, + title: Option, +} + +/// Format a `DateTime` as an HTTP-date (RFC 2822) for Last-Modified headers. +fn to_http_date(dt: &DateTime) -> String { + dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string() +} + +/// Parse an `If-Modified-Since` header value into a `DateTime`. +fn parse_if_modified_since(headers: &HeaderMap) -> Option> { + 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, -) -> Result, (StatusCode, Json)> { - let rows = sqlx::query_as::<_, crate::data::models::CourseAudit>( - "SELECT * FROM course_audits ORDER BY timestamp DESC LIMIT 200", +) -> Result)> { + 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 = 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) } diff --git a/web/bun.lock b/web/bun.lock index 22377b7..e60a506 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/package.json b/web/package.json index c035329..38dc971 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ "vitest": "^3.0.5" }, "dependencies": { + "date-fns": "^4.1.0", "overlayscrollbars": "^2.14.0", "overlayscrollbars-svelte": "^0.5.5" } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a746b3c..9d79dfe 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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("/admin/scrape-jobs"); } - async getAdminAuditLog(): Promise { - return this.request("/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 { + const headers: Record = {}; + 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 { const query = new URLSearchParams(); if (params?.course_id !== undefined) query.set("course_id", String(params.course_id)); diff --git a/web/src/lib/components/SearchStatus.svelte b/web/src/lib/components/SearchStatus.svelte index 7b948c1..0a5e35a 100644 --- a/web/src/lib/components/SearchStatus.svelte +++ b/web/src/lib/components/SearchStatus.svelte @@ -1,61 +1,53 @@ { + 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"); + }); +}); diff --git a/web/src/lib/diff.ts b/web/src/lib/diff.ts new file mode 100644 index 0000000..c34e7a0 --- /dev/null +++ b/web/src/lib/diff.ts @@ -0,0 +1,86 @@ +export interface DiffEntry { + path: string; + oldVal: unknown; + newVal: unknown; +} + +function isObject(val: unknown): val is Record { + 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; + } +} diff --git a/web/src/lib/time.ts b/web/src/lib/time.ts index 34061db..cb9759b 100644 --- a/web/src/lib/time.ts +++ b/web/src/lib/time.ts @@ -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, }; } diff --git a/web/src/routes/(app)/+layout.svelte b/web/src/routes/(app)/+layout.svelte index a1a1e65..53a15bd 100644 --- a/web/src/routes/(app)/+layout.svelte +++ b/web/src/routes/(app)/+layout.svelte @@ -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(); diff --git a/web/src/routes/(app)/admin/audit/+page.svelte b/web/src/routes/(app)/admin/audit/+page.svelte index fb0092b..d8c313d 100644 --- a/web/src/routes/(app)/admin/audit/+page.svelte +++ b/web/src/routes/(app)/admin/audit/+page.svelte @@ -1,49 +1,415 @@ -

Audit Log

+
+

Audit Log

+ {#if spinnerVisible} + + + + {:else if refreshError} + + + + + + {/if} +
-{#if error} +{#if error && !data}

{error}

-{:else if !data} -

Loading...

-{:else if data.entries.length === 0} -

No audit log entries found.

{:else}
- - - - - - - - - - {#each data.entries as entry} - - - - - - + {#each table.getHeaderGroups() as headerGroup} + + {#each headerGroup.headers as header} + + {/each} {/each} + + + {#if !data} + + {#each Array(20) as _} + + {#each columns as col} + + {/each} + + {/each} + {:else if data.entries.length === 0} + + + + {: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"} + 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)} + + {:else if colId === "course"} + + {:else if colId === "field"} + + {:else if colId === "change"} + + {/if} + {/each} + + + {#if isExpanded && change.kind === "json-multi"} + + + + {/if} + {/each} + {/if}
TimeCourse IDFieldOld ValueNew Value
{new Date(entry.timestamp).toLocaleString()}{entry.courseId}{entry.fieldChanged}{entry.oldValue}{entry.newValue}
+ {#if header.column.getCanSort()} + + {#if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} + {#if header.column.getIsSorted() === "asc"} + + {:else if header.column.getIsSorted() === "desc"} + + {:else} + + {/if} + + {:else if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} +
+
+
+ No audit log entries found. +
+ + {rel.text === "now" ? "just now" : `${rel.text} ago`} + + + + {formatCourse(entry)} + + + + {entry.fieldChanged} + + + {#if change.kind === "scalar"} + + {#if change.delta !== null} + {formatDelta(change.delta)}, + {/if} + {change.oldRaw} + + {change.newRaw} + + {:else if change.kind === "json-single"} + {#if change.diffs.length === 1} + {@const d = change.diffs[0]} + + {formatDiffPath(d.path)}: + {" "} + {stringify(d.oldVal)} + + {stringify(d.newVal)} + + {:else} + No changes + {/if} + {:else if change.kind === "json-multi"} + + {#if isExpanded} + + {:else} + + {/if} + + {change.diffs.length} fields changed + + + {/if} +
+
+
+
+ {#each change.diffs as d} +
+ {formatDiffPath(d.path)}: + {" "} + {stringify(d.oldVal)} + + {stringify(d.newVal)} +
+ {/each} +
+
+
+
diff --git a/web/src/routes/(app)/admin/jobs/+page.svelte b/web/src/routes/(app)/admin/jobs/+page.svelte index 683c9a7..e80fb5f 100644 --- a/web/src/routes/(app)/admin/jobs/+page.svelte +++ b/web/src/routes/(app)/admin/jobs/+page.svelte @@ -1,9 +1,21 @@

Scrape Jobs

{#if error}

{error}

-{:else if !data} -

Loading...

-{:else if data.jobs.length === 0} -

No scrape jobs found.

{:else}
- +
- - - - - - - - - - - {#each data.jobs as job} - - - - - - - + {#each table.getHeaderGroups() as headerGroup} + + {#each headerGroup.headers as header} + + {/each} {/each} - + + {#if !data} + + {#each Array(5) as _} + + {#each columns as col} + + {/each} + + {/each} + + {:else if data.jobs.length === 0} + + + + + + {:else} + + {#each table.getRowModel().rows as row} + {@const job = row.original} + + {#each row.getVisibleCells() as cell (cell.id)} + {@const colId = cell.column.id} + {#if colId === "id"} + + {:else if colId === "targetType"} + + {:else if colId === "priority"} + + {:else if colId === "executeAt"} + + {:else if colId === "createdAt"} + + {:else if colId === "retries"} + + {:else if colId === "status"} + + {/if} + {/each} + + {/each} + + {/if}
IDTypePriorityExecute AtRetriesStatus
{job.id}{job.targetType}{job.priority}{new Date(job.executeAt).toLocaleString()}{job.retryCount}/{job.maxRetries}{job.lockedAt ? "Locked" : "Pending"}
+ {#if header.column.getCanSort()} + + {#if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} + {#if header.column.getIsSorted() === "asc"} + + {:else if header.column.getIsSorted() === "desc"} + + {:else} + + {/if} + + {:else if typeof header.column.columnDef.header === "string"} + {header.column.columnDef.header} + {:else} + + {/if} +
+
+
+ No scrape jobs found. +
{job.id} + + {job.targetType} + + + + {job.priority} + + + + + {formatRelativeDate(job.executeAt)} + + + + + + {formatRelativeDate(job.createdAt)} + + + + + {job.retryCount}/{job.maxRetries} + + + {#if job.lockedAt} + + + + Locked + + + {:else} + + + Pending + + {/if} +
{/if}