20 Commits

Author SHA1 Message Date
5a6ea1e53a fix: handle backend startup delays with retry logic in auth 2026-01-29 20:04:50 -06:00
ba2b2fc50a fix: increase Banner API timeouts to handle slow responses 2026-01-29 19:49:57 -06:00
cfe098d193 feat: add websocket support for real-time scrape job monitoring 2026-01-29 19:31:04 -06:00
d861888e5e fix: proper centering for login page content, avoid unnecssary scrollbar 2026-01-29 18:05:50 -06:00
f0645d82d9 refactor: persist audit log state in module scope for cross-navigation caching 2026-01-29 17:54:27 -06:00
7a1cd2a39b refactor: centralize number formatting with locale-aware utility 2026-01-29 17:53:38 -06:00
d2985f98ce feat: enhance audit log with smart diffing, conditional request caching, auto refreshing 2026-01-29 17:35:11 -06:00
b58eb840f3 refactor: consolidate navigation with top nav bar and route groups 2026-01-29 17:01:47 -06:00
2bc6fbdf30 feat: implement relative time feedback and improve tooltip customization 2026-01-29 16:44:06 -06:00
e41b970d6e fix: implement i64 serialization for JavaScript compatibility, fixing avatar URL display 2026-01-29 15:51:19 -06:00
e880126281 feat: implement worker timeout protection and crash recovery for job queue
Add JOB_TIMEOUT constant to fail stuck jobs after 5 minutes, and
LOCK_EXPIRY to reclaim abandoned locks after 10 minutes. Introduce
force_unlock_all to recover orphaned jobs at startup. Fix retry limit
off-by-one error and update deduplication to include locked jobs.
2026-01-29 15:50:09 -06:00
db0ec1e69d feat: add rmp profile links and confidence-aware rating display 2026-01-29 15:43:39 -06:00
2947face06 fix: run frontend build first with -e embed flag in Justfile 2026-01-29 15:00:13 -06:00
36bcc27d7f feat: setup smart page transitions, fix laggy theme-aware element transitions 2026-01-29 14:59:47 -06:00
9e403e5043 refactor: modernize Justfile commands and simplify service management 2026-01-29 14:33:16 -06:00
98a6d978c6 feat: implement course change auditing with time-series metrics endpoint 2026-01-29 14:19:36 -06:00
4deeef2f00 feat: optimize asset delivery with build-time compression and encoding negotiation 2026-01-29 13:56:10 -06:00
e008ee5a12 feat: show search duration and result count feedback 2026-01-29 13:15:25 -06:00
a007ccb6a2 fix: remove out:fade transition from CourseTable 2026-01-29 13:08:45 -06:00
527cbebc6a feat: implement user authentication system with admin dashboard 2026-01-29 12:56:51 -06:00
75 changed files with 6178 additions and 864 deletions
Generated
+155 -3
View File
@@ -26,6 +26,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -106,6 +121,19 @@ dependencies = [
"serde",
]
[[package]]
name = "async-compression"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2"
dependencies = [
"compression-codecs",
"compression-core",
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -154,6 +182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -172,8 +201,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper 1.0.2",
"tokio",
"tokio-tungstenite 0.28.0",
"tower",
"tower-layer",
"tower-service",
@@ -241,7 +272,7 @@ dependencies = [
[[package]]
name = "banner"
version = "0.3.4"
version = "0.5.0"
dependencies = [
"anyhow",
"async-trait",
@@ -284,6 +315,7 @@ dependencies = [
"tracing-subscriber",
"ts-rs",
"url",
"urlencoding",
"yansi",
]
@@ -329,6 +361,27 @@ dependencies = [
"generic-array",
]
[[package]]
name = "brotli"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bstr"
version = "1.12.0"
@@ -406,6 +459,8 @@ version = "1.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc"
dependencies = [
"jobserver",
"libc",
"shlex",
]
@@ -500,6 +555,26 @@ dependencies = [
"time",
]
[[package]]
name = "compression-codecs"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
"zstd",
"zstd-safe",
]
[[package]]
name = "compression-core"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1641,6 +1716,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -2834,7 +2919,7 @@ dependencies = [
"static_assertions",
"time",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.21.0",
"tracing",
"typemap_rev",
"typesize",
@@ -3522,10 +3607,22 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"tungstenite",
"tungstenite 0.21.0",
"webpki-roots 0.26.11",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.28.0",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -3602,14 +3699,17 @@ version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"async-compression",
"bitflags 2.9.4",
"bytes",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
@@ -3721,6 +3821,7 @@ version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424"
dependencies = [
"chrono",
"serde_json",
"thiserror 2.0.16",
"ts-rs-macros",
@@ -3759,6 +3860,23 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http 1.3.1",
"httparse",
"log",
"rand 0.9.2",
"sha1",
"thiserror 2.0.16",
"utf-8",
]
[[package]]
name = "typemap_rev"
version = "0.3.0"
@@ -3860,6 +3978,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
@@ -4555,3 +4679,31 @@ dependencies = [
"quote",
"syn 2.0.106",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
+4 -3
View File
@@ -11,7 +11,7 @@ embed-assets = ["dep:rust-embed", "dep:mime_guess"]
[dependencies]
anyhow = "1.0.99"
async-trait = "0.1"
axum = "0.8.4"
axum = { version = "0.8.4", features = ["ws"] }
bitflags = { version = "2.9.4", features = ["serde"] }
chrono = { version = "0.4.42", features = ["serde"] }
compile-time = "0.2.0"
@@ -48,16 +48,17 @@ url = "2.5"
governor = "0.10.1"
serde_path_to_error = "0.1.17"
num-format = "0.4.4"
tower-http = { version = "0.6.0", features = ["cors", "trace", "timeout"] }
tower-http = { version = "0.6.0", features = ["cors", "trace", "timeout", "compression-full"] }
rust-embed = { version = "8.0", features = ["include-exclude"], optional = true }
mime_guess = { version = "2.0", optional = true }
clap = { version = "4.5", features = ["derive"] }
rapidhash = "4.1.0"
yansi = "1.0.1"
extension-traits = "2"
ts-rs = { version = "11.1.0", features = ["serde-compat", "serde-json-impl"] }
ts-rs = { version = "11.1.0", features = ["chrono-impl", "serde-compat", "serde-json-impl"] }
html-escape = "0.2.13"
axum-extra = { version = "0.12.5", features = ["query"] }
urlencoding = "2.1.3"
[dev-dependencies]
+7 -4
View File
@@ -7,6 +7,9 @@ FROM oven/bun:1 AS frontend-builder
WORKDIR /app
# Install zstd for pre-compression
RUN apt-get update && apt-get install -y --no-install-recommends zstd && rm -rf /var/lib/apt/lists/*
# Copy backend Cargo.toml for build-time version retrieval
COPY ./Cargo.toml ./
@@ -19,8 +22,8 @@ RUN bun install --frozen-lockfile
# Copy frontend source code
COPY ./web ./
# Build frontend
RUN bun run build
# Build frontend, then pre-compress static assets (gzip, brotli, zstd)
RUN bun run build && bun run scripts/compress-assets.ts
# --- Chef Base Stage ---
FROM lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} AS chef
@@ -112,5 +115,5 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
ENV HOSTS=0.0.0.0,[::]
# Implicitly uses PORT environment variable
# temporary: running without 'scraper' service
CMD ["sh", "-c", "exec ./banner --services web,bot"]
# Runs all services: web, bot, and scraper
CMD ["sh", "-c", "exec ./banner"]
+272 -111
View File
@@ -1,51 +1,292 @@
set dotenv-load
default_services := "bot,web,scraper"
default:
just --list
# Run all checks (format, clippy, tests, lint)
check:
cargo fmt --all -- --check
cargo clippy --all-features -- --deny warnings
cargo nextest run -E 'not test(export_bindings)'
bun run --cwd web check
bun run --cwd web test
# Run all checks in parallel. Pass -f/--fix to auto-format and fix first.
[script("bun")]
check *flags:
const args = "{{flags}}".split(/\s+/).filter(Boolean);
let fix = false;
for (const arg of args) {
if (arg === "-f" || arg === "--fix") fix = true;
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
}
# Generate TypeScript bindings from Rust types (ts-rs)
bindings:
cargo test export_bindings
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
# Run all tests (Rust + frontend)
test: test-rust test-web
if (fix) {
console.log("\x1b[1;36m→ Fixing...\x1b[0m");
run(["cargo", "fmt", "--all"]);
run(["bun", "run", "--cwd", "web", "format"]);
run(["cargo", "clippy", "--all-features", "--fix", "--allow-dirty", "--allow-staged",
"--", "--deny", "warnings"]);
console.log("\x1b[1;36m→ Verifying...\x1b[0m");
}
# Run only Rust tests (excludes ts-rs bindings generation)
test-rust *ARGS:
cargo nextest run -E 'not test(export_bindings)' {{ARGS}}
const checks = [
{ name: "rustfmt", cmd: ["cargo", "fmt", "--all", "--", "--check"] },
{ name: "clippy", cmd: ["cargo", "clippy", "--all-features", "--", "--deny", "warnings"] },
{ name: "rust-test", cmd: ["cargo", "nextest", "run", "-E", "not test(export_bindings)"] },
{ name: "svelte-check", cmd: ["bun", "run", "--cwd", "web", "check"] },
{ name: "biome", cmd: ["bun", "run", "--cwd", "web", "format:check"] },
{ name: "web-test", cmd: ["bun", "run", "--cwd", "web", "test"] },
// { name: "sqlx-prepare", cmd: ["cargo", "sqlx", "prepare", "--check"] },
];
# Run only frontend tests
test-web:
bun run --cwd web test
const isTTY = process.stderr.isTTY;
const start = Date.now();
const remaining = new Set(checks.map(c => c.name));
# Quick check: clippy + tests + typecheck (skips formatting)
check-quick:
cargo clippy --all-features -- --deny warnings
cargo nextest run -E 'not test(export_bindings)'
bun run --cwd web check
const promises = checks.map(async (check) => {
const proc = Bun.spawn(check.cmd, {
env: { ...process.env, FORCE_COLOR: "1" },
stdout: "pipe", stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
await proc.exited;
return { ...check, stdout, stderr, exitCode: proc.exitCode,
elapsed: ((Date.now() - start) / 1000).toFixed(1) };
});
# Run the Banner API search demo (hits live UTSA API, ~20s)
search *ARGS:
cargo run -q --bin search -- {{ARGS}}
const interval = isTTY ? setInterval(() => {
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
process.stderr.write(`\r\x1b[K${elapsed}s [${Array.from(remaining).join(", ")}]`);
}, 100) : null;
let anyFailed = false;
for (const promise of promises) {
const r = await promise;
remaining.delete(r.name);
if (isTTY) process.stderr.write(`\r\x1b[K`);
if (r.exitCode !== 0) {
anyFailed = true;
process.stdout.write(`\x1b[31m✗ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
if (r.stdout) process.stdout.write(r.stdout);
if (r.stderr) process.stderr.write(r.stderr);
} else {
process.stdout.write(`\x1b[32m✓ ${r.name}\x1b[0m (${r.elapsed}s)\n`);
}
}
if (interval) clearInterval(interval);
if (isTTY) process.stderr.write(`\r\x1b[K`);
process.exit(anyFailed ? 1 : 0);
# Format all Rust and TypeScript code
format:
cargo fmt --all
bun run --cwd web format
# Check formatting without modifying (CI-friendly)
format-check:
cargo fmt --all -- --check
bun run --cwd web format:check
# Run tests. Usage: just test [rust|web|<nextest filter args>]
[script("bun")]
test *args:
const input = "{{args}}".trim();
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
if (input === "web") {
run(["bun", "run", "--cwd", "web", "test"]);
} else if (input === "rust") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
} else if (input === "") {
run(["cargo", "nextest", "run", "-E", "not test(export_bindings)"]);
run(["bun", "run", "--cwd", "web", "test"]);
} else {
run(["cargo", "nextest", "run", ...input.split(/\s+/)]);
}
# Generate TypeScript bindings from Rust types (ts-rs)
bindings:
cargo test export_bindings
# Run the Banner API search demo (hits live UTSA API, ~20s)
search *ARGS:
cargo run -q --bin search -- {{ARGS}}
# Pass args to binary after --: just dev -n -- --some-flag
# Dev server. Flags: -f(rontend) -b(ackend) -W(no-watch) -n(o-build) -r(elease) -e(mbed) --tracing <fmt>
[script("bun")]
dev *flags:
const argv = "{{flags}}".split(/\s+/).filter(Boolean);
let frontendOnly = false, backendOnly = false;
let noWatch = false, noBuild = false, release = false, embed = false;
let tracing = "pretty";
const passthrough = [];
let i = 0;
let seenDashDash = false;
while (i < argv.length) {
const arg = argv[i];
if (seenDashDash) { passthrough.push(arg); i++; continue; }
if (arg === "--") { seenDashDash = true; i++; continue; }
if (arg.startsWith("--")) {
if (arg === "--frontend-only") frontendOnly = true;
else if (arg === "--backend-only") backendOnly = true;
else if (arg === "--no-watch") noWatch = true;
else if (arg === "--no-build") noBuild = true;
else if (arg === "--release") release = true;
else if (arg === "--embed") embed = true;
else if (arg === "--tracing") { tracing = argv[++i] || "pretty"; }
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
} else if (arg.startsWith("-") && arg.length > 1) {
for (const c of arg.slice(1)) {
if (c === "f") frontendOnly = true;
else if (c === "b") backendOnly = true;
else if (c === "W") noWatch = true;
else if (c === "n") noBuild = true;
else if (c === "r") release = true;
else if (c === "e") embed = true;
else { console.error(`Unknown flag: -${c}`); process.exit(1); }
}
} else { console.error(`Unknown argument: ${arg}`); process.exit(1); }
i++;
}
// -e implies -b (no point running Vite if assets are embedded)
if (embed) backendOnly = true;
// -n implies -W (no build means no watch)
if (noBuild) noWatch = true;
// Validate conflicting flags
if (frontendOnly && backendOnly) {
console.error("Cannot use -f and -b together (or -e implies -b)");
process.exit(1);
}
const runFrontend = !backendOnly;
const runBackend = !frontendOnly;
const profile = release ? "release" : "dev";
const profileDir = release ? "release" : "debug";
const procs = [];
const cleanup = async () => {
for (const p of procs) p.kill();
await Promise.all(procs.map(p => p.exited));
};
process.on("SIGINT", async () => { await cleanup(); process.exit(0); });
process.on("SIGTERM", async () => { await cleanup(); process.exit(0); });
// Build frontend first when embedding assets (backend will bake them in)
if (embed && !noBuild) {
console.log(`\x1b[1;36m→ Building frontend (for embedding)...\x1b[0m`);
const fb = Bun.spawnSync(["bun", "run", "--cwd", "web", "build"], {
stdio: ["inherit", "inherit", "inherit"],
});
if (fb.exitCode !== 0) process.exit(fb.exitCode);
}
// Frontend: Vite dev server
if (runFrontend) {
const proc = Bun.spawn(["bun", "run", "--cwd", "web", "dev"], {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
}
// Backend
if (runBackend) {
const backendArgs = [`--tracing`, tracing, ...passthrough];
const bin = `target/${profileDir}/banner`;
if (noWatch) {
// Build first unless -n (skip build)
if (!noBuild) {
console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`);
const cargoArgs = ["cargo", "build", "--bin", "banner"];
if (!embed) cargoArgs.push("--no-default-features");
if (release) cargoArgs.push("--release");
const build = Bun.spawnSync(cargoArgs, { stdio: ["inherit", "inherit", "inherit"] });
if (build.exitCode !== 0) { cleanup(); process.exit(build.exitCode); }
}
// Run the binary directly (no watch)
const { existsSync } = await import("fs");
if (!existsSync(bin)) {
console.error(`Binary not found: ${bin}`);
console.error(`Run 'just build${release ? "" : " -d"}' first, or remove -n to use bacon.`);
cleanup();
process.exit(1);
}
console.log(`\x1b[1;36m→ Running ${bin} (no watch)\x1b[0m`);
const proc = Bun.spawn([bin, ...backendArgs], {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
} else {
// Bacon watch mode
const baconArgs = ["bacon", "--headless", "run", "--"];
if (!embed) baconArgs.push("--no-default-features");
if (release) baconArgs.push("--profile", "release");
baconArgs.push("--", ...backendArgs);
const proc = Bun.spawn(baconArgs, {
stdio: ["inherit", "inherit", "inherit"],
});
procs.push(proc);
}
}
// Wait for any process to exit, then kill the rest
const results = procs.map((p, i) => p.exited.then(code => ({ i, code })));
const first = await Promise.race(results);
cleanup();
process.exit(first.code);
# Production build. Flags: -d(ebug) -f(rontend-only) -b(ackend-only)
[script("bun")]
build *flags:
const argv = "{{flags}}".split(/\s+/).filter(Boolean);
let debug = false, frontendOnly = false, backendOnly = false;
for (const arg of argv) {
if (arg.startsWith("--")) {
if (arg === "--debug") debug = true;
else if (arg === "--frontend-only") frontendOnly = true;
else if (arg === "--backend-only") backendOnly = true;
else { console.error(`Unknown flag: ${arg}`); process.exit(1); }
} else if (arg.startsWith("-") && arg.length > 1) {
for (const c of arg.slice(1)) {
if (c === "d") debug = true;
else if (c === "f") frontendOnly = true;
else if (c === "b") backendOnly = true;
else { console.error(`Unknown flag: -${c}`); process.exit(1); }
}
} else { console.error(`Unknown argument: ${arg}`); process.exit(1); }
}
if (frontendOnly && backendOnly) {
console.error("Cannot use -f and -b together");
process.exit(1);
}
const run = (cmd) => {
const proc = Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] });
if (proc.exitCode !== 0) process.exit(proc.exitCode);
};
const buildFrontend = !backendOnly;
const buildBackend = !frontendOnly;
const profile = debug ? "debug" : "release";
if (buildFrontend) {
console.log("\x1b[1;36m→ Building frontend...\x1b[0m");
run(["bun", "run", "--cwd", "web", "build"]);
}
if (buildBackend) {
console.log(`\x1b[1;36m→ Building backend (${profile})...\x1b[0m`);
const cmd = ["cargo", "build", "--bin", "banner"];
if (!debug) cmd.push("--release");
run(cmd);
}
# Start PostgreSQL in Docker and update .env with connection string
# Commands: start (default), reset, rm
@@ -115,86 +356,6 @@ db cmd="start":
await updateEnv();
}
# Auto-reloading frontend server
frontend:
bun run --cwd web dev
# Production build of frontend
build-frontend:
bun run --cwd web build
# Auto-reloading backend server (with embedded assets)
backend *ARGS:
bacon --headless run -- -- {{ARGS}}
# Auto-reloading backend server (no embedded assets, for dev proxy mode)
backend-dev *ARGS:
bacon --headless run -- --no-default-features -- {{ARGS}}
# Production build
build:
bun run --cwd web build
cargo build --release --bin banner
# Run auto-reloading development build with release characteristics
dev-build *ARGS='--services web --tracing pretty': build-frontend
bacon --headless run -- --profile dev-release -- {{ARGS}}
# Auto-reloading development build: Vite frontend + backend (no embedded assets, proxies to Vite)
[parallel]
dev *ARGS='--services web,bot': frontend (backend-dev ARGS)
# Smoke test: start web server, hit API endpoints, verify responses
[script("bash")]
test-smoke port="18080":
set -euo pipefail
PORT={{port}}
cleanup() { kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null; }
# Start server in background
PORT=$PORT cargo run -q --no-default-features -- --services web --tracing json &
SERVER_PID=$!
trap cleanup EXIT
# Wait for server to be ready (up to 15s)
for i in $(seq 1 30); do
if curl -sf "http://localhost:$PORT/api/health" >/dev/null 2>&1; then break; fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then echo "FAIL: server exited early"; exit 1; fi
sleep 0.5
done
PASS=0; FAIL=0
check() {
local label="$1" url="$2" expected="$3"
body=$(curl -sf "$url") || { echo "FAIL: $label - request failed"; FAIL=$((FAIL+1)); return; }
if echo "$body" | grep -q "$expected"; then
echo "PASS: $label"
PASS=$((PASS+1))
else
echo "FAIL: $label - expected '$expected' in: $body"
FAIL=$((FAIL+1))
fi
}
check "GET /api/health" "http://localhost:$PORT/api/health" '"status":"healthy"'
check "GET /api/status" "http://localhost:$PORT/api/status" '"version"'
check "GET /api/metrics" "http://localhost:$PORT/api/metrics" '"banner_api"'
# Test 404
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/nonexistent")
if [ "$STATUS" = "404" ]; then
echo "PASS: 404 on unknown route"
PASS=$((PASS+1))
else
echo "FAIL: expected 404, got $STATUS"
FAIL=$((FAIL+1))
fi
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
alias b := bun
bun *ARGS:
cd web && bun {{ ARGS }}
+1 -2
View File
@@ -29,8 +29,7 @@ The application consists of three modular services that can be run independently
bun install --cwd web # Install frontend dependencies
cargo build # Build the backend
just dev # Runs auto-reloading dev build
just dev --services bot,web # Runs auto-reloading dev build, running only the bot and web services
just dev # Runs auto-reloading dev build with all services
just dev-build # Development build with release characteristics (frontend is embedded, non-auto-reloading)
just build # Production build that embeds assets
@@ -0,0 +1,7 @@
-- Add queued_at column to track when a job last entered the "ready to pick up" state.
-- For fresh jobs this equals execute_at; for retried jobs it is updated to NOW().
ALTER TABLE scrape_jobs
ADD COLUMN queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
-- Backfill existing rows: set queued_at = execute_at (best approximation)
UPDATE scrape_jobs SET queued_at = execute_at;
@@ -0,0 +1,19 @@
CREATE TABLE users (
discord_id BIGINT PRIMARY KEY,
discord_username TEXT NOT NULL,
discord_avatar_hash TEXT,
is_admin BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_sessions (
id TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(discord_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
last_active_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at);
+20 -1
View File
@@ -6,6 +6,7 @@ use crate::services::bot::BotService;
use crate::services::manager::ServiceManager;
use crate::services::web::WebService;
use crate::state::AppState;
use crate::web::auth::AuthConfig;
use anyhow::Context;
use figment::value::UncasedStr;
use figment::{Figment, providers::Env};
@@ -84,6 +85,14 @@ impl App {
info!(error = ?e, "Could not load reference cache on startup (may be empty)");
}
// Seed the initial admin user if configured
if let Some(admin_id) = config.admin_discord_id {
let user = crate::data::users::ensure_seed_admin(&db_pool, admin_id as i64)
.await
.context("Failed to seed admin user")?;
info!(discord_id = admin_id, username = %user.discord_username, "Seed admin ensured");
}
Ok(App {
config,
db_pool,
@@ -97,7 +106,16 @@ impl App {
pub fn setup_services(&mut self, services: &[ServiceName]) -> Result<(), anyhow::Error> {
// Register enabled services with the manager
if services.contains(&ServiceName::Web) {
let web_service = Box::new(WebService::new(self.config.port, self.app_state.clone()));
let auth_config = AuthConfig {
client_id: self.config.discord_client_id.clone(),
client_secret: self.config.discord_client_secret.clone(),
redirect_base: self.config.discord_redirect_uri.clone(),
};
let web_service = Box::new(WebService::new(
self.config.port,
self.app_state.clone(),
auth_config,
));
self.service_manager
.register_service(ServiceName::Web.as_str(), web_service);
}
@@ -108,6 +126,7 @@ impl App {
self.banner_api.clone(),
self.app_state.reference_cache.clone(),
self.app_state.service_statuses.clone(),
self.app_state.scrape_job_tx.clone(),
));
self.service_manager
.register_service(ServiceName::Scraper.as_str(), scraper_service);
+3 -3
View File
@@ -40,9 +40,9 @@ impl BannerApi {
.cookie_store(false)
.user_agent(user_agent())
.tcp_keepalive(Some(std::time::Duration::from_secs(60 * 5)))
.read_timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.read_timeout(std::time::Duration::from_secs(20))
.connect_timeout(std::time::Duration::from_secs(15))
.timeout(std::time::Duration::from_secs(40))
.build()
.context("Failed to create HTTP client")?,
)
+1 -1
View File
@@ -1,4 +1,4 @@
use bitflags::{bitflags, Flags};
use bitflags::{Flags, bitflags};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
use extension_traits::extension;
use serde::{Deserialize, Deserializer, Serialize};
+1 -105
View File
@@ -2,34 +2,16 @@ use clap::Parser;
/// Banner Discord Bot - Course availability monitoring
///
/// This application runs multiple services that can be controlled via CLI arguments:
/// This application runs all services:
/// - bot: Discord bot for course monitoring commands
/// - web: HTTP server for web interface and API
/// - scraper: Background service for scraping course data
///
/// Use --services to specify which services to run, or --disable-services to exclude specific services.
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Args {
/// Log formatter to use
#[arg(long, value_enum, default_value_t = default_tracing_format())]
pub tracing: TracingFormat,
/// Services to run (comma-separated). Default: all services
///
/// Examples:
/// --services bot,web # Run only bot and web services
/// --services scraper # Run only the scraper service
#[arg(long, value_delimiter = ',', conflicts_with = "disable_services")]
pub services: Option<Vec<ServiceName>>,
/// Services to disable (comma-separated)
///
/// Examples:
/// --disable-services bot # Run web and scraper only
/// --disable-services bot,web # Run only the scraper service
#[arg(long, value_delimiter = ',', conflicts_with = "services")]
pub disable_services: Option<Vec<ServiceName>>,
}
#[derive(clap::ValueEnum, Clone, Debug)]
@@ -66,34 +48,6 @@ impl ServiceName {
}
}
/// Determine which services should be enabled based on CLI arguments
pub fn determine_enabled_services(args: &Args) -> Result<Vec<ServiceName>, anyhow::Error> {
match (&args.services, &args.disable_services) {
(Some(services), None) => {
// User specified which services to run
Ok(services.clone())
}
(None, Some(disabled)) => {
// User specified which services to disable
let enabled: Vec<ServiceName> = ServiceName::all()
.into_iter()
.filter(|s| !disabled.contains(s))
.collect();
Ok(enabled)
}
(None, None) => {
// Default: run all services
Ok(ServiceName::all())
}
(Some(_), Some(_)) => {
// This should be prevented by clap's conflicts_with, but just in case
Err(anyhow::anyhow!(
"Cannot specify both --services and --disable-services"
))
}
}
}
#[cfg(debug_assertions)]
const DEFAULT_TRACING_FORMAT: TracingFormat = TracingFormat::Pretty;
#[cfg(not(debug_assertions))]
@@ -107,64 +61,6 @@ fn default_tracing_format() -> TracingFormat {
mod tests {
use super::*;
fn args_with_services(
services: Option<Vec<ServiceName>>,
disable: Option<Vec<ServiceName>>,
) -> Args {
Args {
tracing: TracingFormat::Pretty,
services,
disable_services: disable,
}
}
#[test]
fn test_default_enables_all_services() {
let result = determine_enabled_services(&args_with_services(None, None)).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_explicit_services_only_those() {
let result =
determine_enabled_services(&args_with_services(Some(vec![ServiceName::Web]), None))
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].as_str(), "web");
}
#[test]
fn test_disable_bot_leaves_web_and_scraper() {
let result =
determine_enabled_services(&args_with_services(None, Some(vec![ServiceName::Bot])))
.unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().all(|s| s.as_str() != "bot"));
}
#[test]
fn test_disable_all_leaves_empty() {
let result = determine_enabled_services(&args_with_services(
None,
Some(vec![
ServiceName::Bot,
ServiceName::Web,
ServiceName::Scraper,
]),
))
.unwrap();
assert!(result.is_empty());
}
#[test]
fn test_both_specified_returns_error() {
let result = determine_enabled_services(&args_with_services(
Some(vec![ServiceName::Web]),
Some(vec![ServiceName::Bot]),
));
assert!(result.is_err());
}
#[test]
fn test_service_name_as_str() {
assert_eq!(ServiceName::Bot.as_str(), "bot");
+50
View File
@@ -47,6 +47,19 @@ pub struct Config {
/// Rate limiting configuration for Banner API requests
#[serde(default = "default_rate_limiting")]
pub rate_limiting: RateLimitingConfig,
/// Discord OAuth2 client ID for web authentication
#[serde(deserialize_with = "deserialize_string_or_uint")]
pub discord_client_id: String,
/// Discord OAuth2 client secret for web authentication
pub discord_client_secret: String,
/// Optional base URL override for OAuth2 redirect (e.g. "https://banner.xevion.dev").
/// When unset, the redirect URI is derived from the incoming request's Origin/Host.
#[serde(default)]
pub discord_redirect_uri: Option<String>,
/// Discord user ID to seed as initial admin on startup (optional)
#[serde(default)]
pub admin_discord_id: Option<u64>,
}
/// Default log level of "info"
@@ -216,6 +229,43 @@ where
deserializer.deserialize_any(DurationVisitor)
}
/// Deserializes a value that may arrive as either a string or unsigned integer.
///
/// Figment's env provider infers types from raw values, so numeric-looking strings
/// like Discord client IDs get parsed as integers. This accepts both forms.
fn deserialize_string_or_uint<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Visitor;
struct StringOrUintVisitor;
impl<'de> Visitor<'de> for StringOrUintVisitor {
type Value = String;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or unsigned integer")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(value.to_owned())
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(value.to_string())
}
}
deserializer.deserialize_any(StringOrUintVisitor)
}
#[cfg(test)]
mod tests {
use super::*;
+438 -78
View File
@@ -3,6 +3,7 @@
use crate::banner::Course;
use crate::data::models::DbMeetingTime;
use crate::error::Result;
use sqlx::PgConnection;
use sqlx::PgPool;
use std::collections::HashSet;
use std::time::Instant;
@@ -57,15 +58,315 @@ fn extract_campus_code(course: &Course) -> Option<String> {
.and_then(|mf| mf.meeting_time.campus.clone())
}
// ---------------------------------------------------------------------------
// Task 1: UpsertDiffRow — captures pre- and post-upsert state for diffing
// ---------------------------------------------------------------------------
/// Row returned by the CTE-based upsert query, carrying both old and new values
/// for every auditable field. `old_id` is `None` for fresh inserts.
#[derive(sqlx::FromRow, Debug)]
struct UpsertDiffRow {
id: i32,
old_id: Option<i32>,
// enrollment fields
old_enrollment: Option<i32>,
new_enrollment: i32,
old_max_enrollment: Option<i32>,
new_max_enrollment: i32,
old_wait_count: Option<i32>,
new_wait_count: i32,
old_wait_capacity: Option<i32>,
new_wait_capacity: i32,
// text fields (non-nullable in DB)
old_subject: Option<String>,
new_subject: String,
old_course_number: Option<String>,
new_course_number: String,
old_title: Option<String>,
new_title: String,
// nullable text fields
old_sequence_number: Option<String>,
new_sequence_number: Option<String>,
old_part_of_term: Option<String>,
new_part_of_term: Option<String>,
old_instructional_method: Option<String>,
new_instructional_method: Option<String>,
old_campus: Option<String>,
new_campus: Option<String>,
// nullable int fields
old_credit_hours: Option<i32>,
new_credit_hours: Option<i32>,
old_credit_hour_low: Option<i32>,
new_credit_hour_low: Option<i32>,
old_credit_hour_high: Option<i32>,
new_credit_hour_high: Option<i32>,
// cross-list fields
old_cross_list: Option<String>,
new_cross_list: Option<String>,
old_cross_list_capacity: Option<i32>,
new_cross_list_capacity: Option<i32>,
old_cross_list_count: Option<i32>,
new_cross_list_count: Option<i32>,
// link fields
old_link_identifier: Option<String>,
new_link_identifier: Option<String>,
old_is_section_linked: Option<bool>,
new_is_section_linked: Option<bool>,
// JSONB fields
old_meeting_times: Option<serde_json::Value>,
new_meeting_times: serde_json::Value,
old_attributes: Option<serde_json::Value>,
new_attributes: serde_json::Value,
}
// ---------------------------------------------------------------------------
// Task 3: Entry types and diff logic
// ---------------------------------------------------------------------------
struct AuditEntry {
course_id: i32,
field_changed: &'static str,
old_value: String,
new_value: String,
}
struct MetricEntry {
course_id: i32,
enrollment: i32,
wait_count: i32,
seats_available: i32,
}
/// Compare old vs new for a single field, pushing an `AuditEntry` when they differ.
///
/// Three variants:
/// - `diff_field!(audits, row, field_name, old_field, new_field)` — `Option<T>` old vs `T` new
/// - `diff_field!(opt audits, row, field_name, old_field, new_field)` — `Option<T>` old vs `Option<T>` new
/// - `diff_field!(json audits, row, field_name, old_field, new_field)` — `Option<Value>` old vs `Value` new
///
/// All variants skip when `old_id` is None (fresh insert).
macro_rules! diff_field {
// Standard: Option<T> old vs T new (non-nullable columns)
($audits:ident, $row:ident, $field:expr, $old:ident, $new:ident) => {
if $row.old_id.is_some() {
let old_str = $row
.$old
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
let new_str = $row.$new.to_string();
if old_str != new_str {
$audits.push(AuditEntry {
course_id: $row.id,
field_changed: $field,
old_value: old_str,
new_value: new_str,
});
}
}
};
// Nullable: Option<T> old vs Option<T> new
(opt $audits:ident, $row:ident, $field:expr, $old:ident, $new:ident) => {
if $row.old_id.is_some() {
let old_str = $row
.$old
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
let new_str = $row
.$new
.as_ref()
.map(|v| v.to_string())
.unwrap_or_default();
if old_str != new_str {
$audits.push(AuditEntry {
course_id: $row.id,
field_changed: $field,
old_value: old_str,
new_value: new_str,
});
}
}
};
// JSONB: Option<Value> old vs Value new
(json $audits:ident, $row:ident, $field:expr, $old:ident, $new:ident) => {
if $row.old_id.is_some() {
let old_val = $row
.$old
.as_ref()
.cloned()
.unwrap_or(serde_json::Value::Null);
let new_val = &$row.$new;
if old_val != *new_val {
$audits.push(AuditEntry {
course_id: $row.id,
field_changed: $field,
old_value: old_val.to_string(),
new_value: new_val.to_string(),
});
}
}
};
}
/// Compute audit entries (field-level diffs) and metric entries from upsert diff rows.
fn compute_diffs(rows: &[UpsertDiffRow]) -> (Vec<AuditEntry>, Vec<MetricEntry>) {
let mut audits = Vec::new();
let mut metrics = Vec::new();
for row in rows {
// Non-nullable fields
diff_field!(audits, row, "enrollment", old_enrollment, new_enrollment);
diff_field!(
audits,
row,
"max_enrollment",
old_max_enrollment,
new_max_enrollment
);
diff_field!(audits, row, "wait_count", old_wait_count, new_wait_count);
diff_field!(
audits,
row,
"wait_capacity",
old_wait_capacity,
new_wait_capacity
);
diff_field!(audits, row, "subject", old_subject, new_subject);
diff_field!(
audits,
row,
"course_number",
old_course_number,
new_course_number
);
diff_field!(audits, row, "title", old_title, new_title);
// Nullable text fields
diff_field!(opt audits, row, "sequence_number", old_sequence_number, new_sequence_number);
diff_field!(opt audits, row, "part_of_term", old_part_of_term, new_part_of_term);
diff_field!(opt audits, row, "instructional_method", old_instructional_method, new_instructional_method);
diff_field!(opt audits, row, "campus", old_campus, new_campus);
// Nullable int fields
diff_field!(opt audits, row, "credit_hours", old_credit_hours, new_credit_hours);
diff_field!(opt audits, row, "credit_hour_low", old_credit_hour_low, new_credit_hour_low);
diff_field!(opt audits, row, "credit_hour_high", old_credit_hour_high, new_credit_hour_high);
// Cross-list fields
diff_field!(opt audits, row, "cross_list", old_cross_list, new_cross_list);
diff_field!(opt audits, row, "cross_list_capacity", old_cross_list_capacity, new_cross_list_capacity);
diff_field!(opt audits, row, "cross_list_count", old_cross_list_count, new_cross_list_count);
// Link fields
diff_field!(opt audits, row, "link_identifier", old_link_identifier, new_link_identifier);
diff_field!(opt audits, row, "is_section_linked", old_is_section_linked, new_is_section_linked);
// JSONB fields
diff_field!(json audits, row, "meeting_times", old_meeting_times, new_meeting_times);
diff_field!(json audits, row, "attributes", old_attributes, new_attributes);
// Emit a metric entry when enrollment/wait_count/max_enrollment changed
// Skip fresh inserts (no old data to compare against)
let enrollment_changed = row.old_id.is_some()
&& (row.old_enrollment != Some(row.new_enrollment)
|| row.old_wait_count != Some(row.new_wait_count)
|| row.old_max_enrollment != Some(row.new_max_enrollment));
if enrollment_changed {
metrics.push(MetricEntry {
course_id: row.id,
enrollment: row.new_enrollment,
wait_count: row.new_wait_count,
seats_available: row.new_max_enrollment - row.new_enrollment,
});
}
}
(audits, metrics)
}
// ---------------------------------------------------------------------------
// Task 4: Batch insert functions for audits and metrics
// ---------------------------------------------------------------------------
async fn insert_audits(audits: &[AuditEntry], conn: &mut PgConnection) -> Result<()> {
if audits.is_empty() {
return Ok(());
}
let course_ids: Vec<i32> = audits.iter().map(|a| a.course_id).collect();
let fields: Vec<&str> = audits.iter().map(|a| a.field_changed).collect();
let old_values: Vec<&str> = audits.iter().map(|a| a.old_value.as_str()).collect();
let new_values: Vec<&str> = audits.iter().map(|a| a.new_value.as_str()).collect();
sqlx::query(
r#"
INSERT INTO course_audits (course_id, timestamp, field_changed, old_value, new_value)
SELECT v.course_id, NOW(), v.field_changed, v.old_value, v.new_value
FROM UNNEST($1::int4[], $2::text[], $3::text[], $4::text[])
AS v(course_id, field_changed, old_value, new_value)
"#,
)
.bind(&course_ids)
.bind(&fields)
.bind(&old_values)
.bind(&new_values)
.execute(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch insert course_audits: {}", e))?;
Ok(())
}
async fn insert_metrics(metrics: &[MetricEntry], conn: &mut PgConnection) -> Result<()> {
if metrics.is_empty() {
return Ok(());
}
let course_ids: Vec<i32> = metrics.iter().map(|m| m.course_id).collect();
let enrollments: Vec<i32> = metrics.iter().map(|m| m.enrollment).collect();
let wait_counts: Vec<i32> = metrics.iter().map(|m| m.wait_count).collect();
let seats_available: Vec<i32> = metrics.iter().map(|m| m.seats_available).collect();
sqlx::query(
r#"
INSERT INTO course_metrics (course_id, timestamp, enrollment, wait_count, seats_available)
SELECT v.course_id, NOW(), v.enrollment, v.wait_count, v.seats_available
FROM UNNEST($1::int4[], $2::int4[], $3::int4[], $4::int4[])
AS v(course_id, enrollment, wait_count, seats_available)
"#,
)
.bind(&course_ids)
.bind(&enrollments)
.bind(&wait_counts)
.bind(&seats_available)
.execute(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch insert course_metrics: {}", e))?;
Ok(())
}
// ---------------------------------------------------------------------------
// Core upsert functions (updated to use &mut PgConnection)
// ---------------------------------------------------------------------------
/// Batch upsert courses in a single database query.
///
/// Performs a bulk INSERT...ON CONFLICT DO UPDATE for all courses, including
/// new fields (meeting times, attributes, instructor data). Returns the
/// database IDs for all upserted courses (in input order) so instructors
/// can be linked.
/// new fields (meeting times, attributes, instructor data). Captures pre-update
/// state for audit/metric tracking, all within a single transaction.
///
/// # Performance
/// - Reduces N database round-trips to 3 (courses, instructors, junction)
/// - Reduces N database round-trips to 5 (old-data CTE + upsert, audits, metrics, instructors, junction)
/// - Typical usage: 50-200 courses per batch
pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<()> {
if courses.is_empty() {
@@ -76,27 +377,47 @@ pub async fn batch_upsert_courses(courses: &[Course], db_pool: &PgPool) -> Resul
let start = Instant::now();
let course_count = courses.len();
// Step 1: Upsert courses with all fields, returning IDs
let course_ids = upsert_courses(courses, db_pool).await?;
let mut tx = db_pool.begin().await?;
// Step 2: Upsert instructors (deduplicated across batch)
upsert_instructors(courses, db_pool).await?;
// Step 1: Upsert courses with CTE, returning diff rows
let diff_rows = upsert_courses(courses, &mut tx).await?;
// Step 3: Link courses to instructors via junction table
upsert_course_instructors(courses, &course_ids, db_pool).await?;
// Step 2: Extract course IDs for instructor linking
let course_ids: Vec<i32> = diff_rows.iter().map(|r| r.id).collect();
// Step 3: Compute audit/metric diffs
let (audits, metrics) = compute_diffs(&diff_rows);
// Step 4: Insert audits and metrics
insert_audits(&audits, &mut tx).await?;
insert_metrics(&metrics, &mut tx).await?;
// Step 5: Upsert instructors (deduplicated across batch)
upsert_instructors(courses, &mut tx).await?;
// Step 6: Link courses to instructors via junction table
upsert_course_instructors(courses, &course_ids, &mut tx).await?;
tx.commit().await?;
let duration = start.elapsed();
info!(
courses_count = course_count,
audit_entries = audits.len(),
metric_entries = metrics.len(),
duration_ms = duration.as_millis(),
"Batch upserted courses with instructors"
"Batch upserted courses with instructors, audits, and metrics"
);
Ok(())
}
/// Upsert all courses and return their database IDs in input order.
async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>> {
// ---------------------------------------------------------------------------
// Task 2: CTE-based upsert returning old+new values
// ---------------------------------------------------------------------------
/// Upsert all courses and return diff rows with old and new values for auditing.
async fn upsert_courses(courses: &[Course], conn: &mut PgConnection) -> Result<Vec<UpsertDiffRow>> {
let crns: Vec<&str> = courses
.iter()
.map(|c| c.course_reference_number.as_str())
@@ -143,67 +464,106 @@ async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>
courses.iter().map(to_db_meeting_times).collect();
let attributes_json: Vec<serde_json::Value> = courses.iter().map(to_db_attributes).collect();
let rows = sqlx::query_scalar::<_, i32>(
let rows = sqlx::query_as::<_, UpsertDiffRow>(
r#"
INSERT INTO courses (
crn, subject, course_number, title, term_code,
enrollment, max_enrollment, wait_count, wait_capacity, last_scraped_at,
sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked,
meeting_times, attributes
WITH old_data AS (
SELECT id, enrollment, max_enrollment, wait_count, wait_capacity,
subject, course_number, title,
sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked,
meeting_times, attributes,
crn, term_code
FROM courses
WHERE (crn, term_code) IN (SELECT * FROM UNNEST($1::text[], $5::text[]))
),
upserted AS (
INSERT INTO courses (
crn, subject, course_number, title, term_code,
enrollment, max_enrollment, wait_count, wait_capacity, last_scraped_at,
sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked,
meeting_times, attributes
)
SELECT
v.crn, v.subject, v.course_number, v.title, v.term_code,
v.enrollment, v.max_enrollment, v.wait_count, v.wait_capacity, NOW(),
v.sequence_number, v.part_of_term, v.instructional_method, v.campus,
v.credit_hours, v.credit_hour_low, v.credit_hour_high,
v.cross_list, v.cross_list_capacity, v.cross_list_count,
v.link_identifier, v.is_section_linked,
v.meeting_times, v.attributes
FROM UNNEST(
$1::text[], $2::text[], $3::text[], $4::text[], $5::text[],
$6::int4[], $7::int4[], $8::int4[], $9::int4[],
$10::text[], $11::text[], $12::text[], $13::text[],
$14::int4[], $15::int4[], $16::int4[],
$17::text[], $18::int4[], $19::int4[],
$20::text[], $21::bool[],
$22::jsonb[], $23::jsonb[]
) AS v(
crn, subject, course_number, title, term_code,
enrollment, max_enrollment, wait_count, wait_capacity,
sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked,
meeting_times, attributes
)
ON CONFLICT (crn, term_code)
DO UPDATE SET
subject = EXCLUDED.subject,
course_number = EXCLUDED.course_number,
title = EXCLUDED.title,
enrollment = EXCLUDED.enrollment,
max_enrollment = EXCLUDED.max_enrollment,
wait_count = EXCLUDED.wait_count,
wait_capacity = EXCLUDED.wait_capacity,
last_scraped_at = EXCLUDED.last_scraped_at,
sequence_number = EXCLUDED.sequence_number,
part_of_term = EXCLUDED.part_of_term,
instructional_method = EXCLUDED.instructional_method,
campus = EXCLUDED.campus,
credit_hours = EXCLUDED.credit_hours,
credit_hour_low = EXCLUDED.credit_hour_low,
credit_hour_high = EXCLUDED.credit_hour_high,
cross_list = EXCLUDED.cross_list,
cross_list_capacity = EXCLUDED.cross_list_capacity,
cross_list_count = EXCLUDED.cross_list_count,
link_identifier = EXCLUDED.link_identifier,
is_section_linked = EXCLUDED.is_section_linked,
meeting_times = EXCLUDED.meeting_times,
attributes = EXCLUDED.attributes
RETURNING *
)
SELECT
v.crn, v.subject, v.course_number, v.title, v.term_code,
v.enrollment, v.max_enrollment, v.wait_count, v.wait_capacity, NOW(),
v.sequence_number, v.part_of_term, v.instructional_method, v.campus,
v.credit_hours, v.credit_hour_low, v.credit_hour_high,
v.cross_list, v.cross_list_capacity, v.cross_list_count,
v.link_identifier, v.is_section_linked,
v.meeting_times, v.attributes
FROM UNNEST(
$1::text[], $2::text[], $3::text[], $4::text[], $5::text[],
$6::int4[], $7::int4[], $8::int4[], $9::int4[],
$10::text[], $11::text[], $12::text[], $13::text[],
$14::int4[], $15::int4[], $16::int4[],
$17::text[], $18::int4[], $19::int4[],
$20::text[], $21::bool[],
$22::jsonb[], $23::jsonb[]
) AS v(
crn, subject, course_number, title, term_code,
enrollment, max_enrollment, wait_count, wait_capacity,
sequence_number, part_of_term, instructional_method, campus,
credit_hours, credit_hour_low, credit_hour_high,
cross_list, cross_list_capacity, cross_list_count,
link_identifier, is_section_linked,
meeting_times, attributes
)
ON CONFLICT (crn, term_code)
DO UPDATE SET
subject = EXCLUDED.subject,
course_number = EXCLUDED.course_number,
title = EXCLUDED.title,
enrollment = EXCLUDED.enrollment,
max_enrollment = EXCLUDED.max_enrollment,
wait_count = EXCLUDED.wait_count,
wait_capacity = EXCLUDED.wait_capacity,
last_scraped_at = EXCLUDED.last_scraped_at,
sequence_number = EXCLUDED.sequence_number,
part_of_term = EXCLUDED.part_of_term,
instructional_method = EXCLUDED.instructional_method,
campus = EXCLUDED.campus,
credit_hours = EXCLUDED.credit_hours,
credit_hour_low = EXCLUDED.credit_hour_low,
credit_hour_high = EXCLUDED.credit_hour_high,
cross_list = EXCLUDED.cross_list,
cross_list_capacity = EXCLUDED.cross_list_capacity,
cross_list_count = EXCLUDED.cross_list_count,
link_identifier = EXCLUDED.link_identifier,
is_section_linked = EXCLUDED.is_section_linked,
meeting_times = EXCLUDED.meeting_times,
attributes = EXCLUDED.attributes
RETURNING id
SELECT u.id,
o.id AS old_id,
o.enrollment AS old_enrollment, u.enrollment AS new_enrollment,
o.max_enrollment AS old_max_enrollment, u.max_enrollment AS new_max_enrollment,
o.wait_count AS old_wait_count, u.wait_count AS new_wait_count,
o.wait_capacity AS old_wait_capacity, u.wait_capacity AS new_wait_capacity,
o.subject AS old_subject, u.subject AS new_subject,
o.course_number AS old_course_number, u.course_number AS new_course_number,
o.title AS old_title, u.title AS new_title,
o.sequence_number AS old_sequence_number, u.sequence_number AS new_sequence_number,
o.part_of_term AS old_part_of_term, u.part_of_term AS new_part_of_term,
o.instructional_method AS old_instructional_method, u.instructional_method AS new_instructional_method,
o.campus AS old_campus, u.campus AS new_campus,
o.credit_hours AS old_credit_hours, u.credit_hours AS new_credit_hours,
o.credit_hour_low AS old_credit_hour_low, u.credit_hour_low AS new_credit_hour_low,
o.credit_hour_high AS old_credit_hour_high, u.credit_hour_high AS new_credit_hour_high,
o.cross_list AS old_cross_list, u.cross_list AS new_cross_list,
o.cross_list_capacity AS old_cross_list_capacity, u.cross_list_capacity AS new_cross_list_capacity,
o.cross_list_count AS old_cross_list_count, u.cross_list_count AS new_cross_list_count,
o.link_identifier AS old_link_identifier, u.link_identifier AS new_link_identifier,
o.is_section_linked AS old_is_section_linked, u.is_section_linked AS new_is_section_linked,
o.meeting_times AS old_meeting_times, u.meeting_times AS new_meeting_times,
o.attributes AS old_attributes, u.attributes AS new_attributes
FROM upserted u
LEFT JOIN old_data o ON u.crn = o.crn AND u.term_code = o.term_code
"#,
)
.bind(&crns)
@@ -229,7 +589,7 @@ async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>
.bind(&is_section_linkeds)
.bind(&meeting_times_json)
.bind(&attributes_json)
.fetch_all(db_pool)
.fetch_all(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch upsert courses: {}", e))?;
@@ -237,7 +597,7 @@ async fn upsert_courses(courses: &[Course], db_pool: &PgPool) -> Result<Vec<i32>
}
/// Deduplicate and upsert all instructors from the batch.
async fn upsert_instructors(courses: &[Course], db_pool: &PgPool) -> Result<()> {
async fn upsert_instructors(courses: &[Course], conn: &mut PgConnection) -> Result<()> {
let mut seen = HashSet::new();
let mut banner_ids = Vec::new();
let mut display_names = Vec::new();
@@ -270,7 +630,7 @@ async fn upsert_instructors(courses: &[Course], db_pool: &PgPool) -> Result<()>
.bind(&banner_ids)
.bind(&display_names)
.bind(&emails)
.execute(db_pool)
.execute(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch upsert instructors: {}", e))?;
@@ -281,7 +641,7 @@ async fn upsert_instructors(courses: &[Course], db_pool: &PgPool) -> Result<()>
async fn upsert_course_instructors(
courses: &[Course],
course_ids: &[i32],
db_pool: &PgPool,
conn: &mut PgConnection,
) -> Result<()> {
let mut cids = Vec::new();
let mut iids = Vec::new();
@@ -303,7 +663,7 @@ async fn upsert_course_instructors(
// This handles instructor changes cleanly.
sqlx::query("DELETE FROM course_instructors WHERE course_id = ANY($1)")
.bind(&cids)
.execute(db_pool)
.execute(&mut *conn)
.await?;
sqlx::query(
@@ -317,7 +677,7 @@ async fn upsert_course_instructors(
.bind(&cids)
.bind(&iids)
.bind(&primaries)
.execute(db_pool)
.execute(&mut *conn)
.await
.map_err(|e| anyhow::anyhow!("Failed to batch upsert course_instructors: {}", e))?;
+4 -5
View File
@@ -92,9 +92,8 @@ pub async fn search_courses(
) -> Result<(Vec<Course>, i64)> {
let order_by = sort_clause(sort_by, sort_dir);
let data_query = format!(
"SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10"
);
let data_query =
format!("SELECT * FROM courses {SEARCH_WHERE} ORDER BY {order_by} LIMIT $9 OFFSET $10");
let count_query = format!("SELECT COUNT(*) FROM courses {SEARCH_WHERE}");
let courses = sqlx::query_as::<_, Course>(&data_query)
@@ -149,7 +148,7 @@ pub async fn get_course_instructors(
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings,
rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
ci.course_id
FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id
@@ -178,7 +177,7 @@ pub async fn get_instructors_for_courses(
let rows = sqlx::query_as::<_, CourseInstructorDetail>(
r#"
SELECT i.banner_id, i.display_name, i.email, ci.is_primary,
rp.avg_rating, rp.num_ratings,
rp.avg_rating, rp.num_ratings, i.rmp_legacy_id,
ci.course_id
FROM course_instructors ci
JOIN instructors i ON i.banner_id = ci.instructor_id
+2
View File
@@ -6,3 +6,5 @@ pub mod models;
pub mod reference;
pub mod rmp;
pub mod scrape_jobs;
pub mod sessions;
pub mod users;
+102 -1
View File
@@ -1,10 +1,46 @@
//! `sqlx` models for the database schema.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use ts_rs::TS;
/// Serialize an `i64` as a string to avoid JavaScript precision loss for values exceeding 2^53.
fn serialize_i64_as_string<S: Serializer>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&value.to_string())
}
/// Deserialize an `i64` from either a number or a string.
fn deserialize_i64_from_string<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<i64, D::Error> {
use serde::de;
struct I64OrStringVisitor;
impl<'de> de::Visitor<'de> for I64OrStringVisitor {
type Value = i64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an integer or a string containing an integer")
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<i64, E> {
Ok(value)
}
fn visit_u64<E: de::Error>(self, value: u64) -> Result<i64, E> {
i64::try_from(value).map_err(|_| E::custom(format!("u64 {value} out of i64 range")))
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<i64, E> {
value.parse().map_err(de::Error::custom)
}
}
deserializer.deserialize_any(I64OrStringVisitor)
}
/// Represents a meeting time stored as JSONB in the courses table.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
@@ -85,6 +121,7 @@ pub struct CourseInstructorDetail {
pub is_primary: bool,
pub avg_rating: Option<f32>,
pub num_ratings: Option<i32>,
pub rmp_legacy_id: Option<i32>,
/// Present when fetched via batch query; `None` for single-course queries.
pub course_id: Option<i32>,
}
@@ -139,6 +176,20 @@ pub enum TargetType {
SingleCrn,
}
/// Computed status for a scrape job, derived from existing fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ScrapeJobStatus {
Processing,
StaleLock,
Exhausted,
Scheduled,
Pending,
}
/// How long a lock can be held before it is considered stale (mirrors `scrape_jobs::LOCK_EXPIRY`).
const LOCK_EXPIRY_SECS: i64 = 10 * 60;
/// Represents a queryable job from the database.
#[allow(dead_code)]
#[derive(sqlx::FromRow, Debug, Clone)]
@@ -154,4 +205,54 @@ pub struct ScrapeJob {
pub retry_count: i32,
/// Maximum number of retry attempts allowed (non-negative, enforced by CHECK constraint)
pub max_retries: i32,
/// When the job last entered the "ready to pick up" state.
/// Set to NOW() on creation; updated to NOW() on retry.
pub queued_at: DateTime<Utc>,
}
impl ScrapeJob {
/// Compute the current status of this job from its fields.
pub fn status(&self) -> ScrapeJobStatus {
let now = Utc::now();
match self.locked_at {
Some(locked) if (now - locked).num_seconds() < LOCK_EXPIRY_SECS => {
ScrapeJobStatus::Processing
}
Some(_) => ScrapeJobStatus::StaleLock,
None if self.retry_count >= self.max_retries && self.max_retries > 0 => {
ScrapeJobStatus::Exhausted
}
None if self.execute_at > now => ScrapeJobStatus::Scheduled,
None => ScrapeJobStatus::Pending,
}
}
}
/// A user authenticated via Discord OAuth.
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct User {
#[serde(
serialize_with = "serialize_i64_as_string",
deserialize_with = "deserialize_i64_from_string"
)]
#[ts(type = "string")]
pub discord_id: i64,
pub discord_username: String,
pub discord_avatar_hash: Option<String>,
pub is_admin: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A server-side session for an authenticated user.
#[allow(dead_code)] // Fields read via sqlx::FromRow; some only used in DB queries
#[derive(sqlx::FromRow, Debug, Clone)]
pub struct UserSession {
pub id: String,
pub user_id: i64,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub last_active_at: DateTime<Utc>,
}
+58 -25
View File
@@ -5,11 +5,35 @@ use crate::error::Result;
use sqlx::PgPool;
use std::collections::HashSet;
/// Force-unlock all jobs that have a non-NULL `locked_at`.
///
/// Intended to be called once at startup to recover jobs left locked by
/// a previous unclean shutdown (crash, OOM kill, etc.).
///
/// # Returns
/// The number of jobs that were unlocked.
pub async fn force_unlock_all(db_pool: &PgPool) -> Result<u64> {
let result = sqlx::query(
"UPDATE scrape_jobs SET locked_at = NULL, queued_at = NOW() WHERE locked_at IS NOT NULL",
)
.execute(db_pool)
.await?;
Ok(result.rows_affected())
}
/// How long a lock can be held before it is considered expired and reclaimable.
///
/// This acts as a safety net for cases where a worker dies without unlocking
/// (OOM kill, crash, network partition). Under normal operation, the worker's
/// own job timeout fires well before this threshold.
const LOCK_EXPIRY: std::time::Duration = std::time::Duration::from_secs(10 * 60);
/// Atomically fetch and lock the next available scrape job.
///
/// Uses `FOR UPDATE SKIP LOCKED` to allow multiple workers to poll the queue
/// concurrently without conflicts. Only jobs that are unlocked and ready to
/// execute (based on `execute_at`) are considered.
/// concurrently without conflicts. Considers jobs that are:
/// - Unlocked and ready to execute, OR
/// - Locked but past [`LOCK_EXPIRY`] (abandoned by a dead worker)
///
/// # Arguments
/// * `db_pool` - PostgreSQL connection pool
@@ -20,9 +44,16 @@ use std::collections::HashSet;
pub async fn fetch_and_lock_job(db_pool: &PgPool) -> Result<Option<ScrapeJob>> {
let mut tx = db_pool.begin().await?;
let lock_expiry_secs = LOCK_EXPIRY.as_secs() as i32;
let job = sqlx::query_as::<_, ScrapeJob>(
"SELECT * FROM scrape_jobs WHERE locked_at IS NULL AND execute_at <= NOW() ORDER BY priority DESC, execute_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED"
"SELECT * FROM scrape_jobs \
WHERE (locked_at IS NULL OR locked_at < NOW() - make_interval(secs => $1::double precision)) \
AND execute_at <= NOW() \
ORDER BY priority DESC, execute_at ASC \
LIMIT 1 \
FOR UPDATE SKIP LOCKED"
)
.bind(lock_expiry_secs)
.fetch_optional(&mut *tx)
.await?;
@@ -68,10 +99,11 @@ pub async fn unlock_job(job_id: i32, db_pool: &PgPool) -> Result<()> {
Ok(())
}
/// Atomically unlock a job and increment its retry count.
/// Atomically unlock a job, increment its retry count, and reset `queued_at`.
///
/// Returns whether the job still has retries remaining. This is determined
/// atomically in the database to avoid race conditions between workers.
/// Returns the new `queued_at` timestamp if retries remain, or `None` if
/// the job has exhausted its retries. This is determined atomically in the
/// database to avoid race conditions between workers.
///
/// # Arguments
/// * `job_id` - The database ID of the job
@@ -79,31 +111,31 @@ pub async fn unlock_job(job_id: i32, db_pool: &PgPool) -> Result<()> {
/// * `db_pool` - PostgreSQL connection pool
///
/// # Returns
/// * `Ok(true)` if the job was unlocked and retries remain
/// * `Ok(false)` if the job has exhausted its retries
/// * `Ok(Some(queued_at))` if the job was unlocked and retries remain
/// * `Ok(None)` if the job has exhausted its retries
pub async fn unlock_and_increment_retry(
job_id: i32,
max_retries: i32,
db_pool: &PgPool,
) -> Result<bool> {
let result = sqlx::query_scalar::<_, Option<i32>>(
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
let result = sqlx::query_scalar::<_, Option<chrono::DateTime<chrono::Utc>>>(
"UPDATE scrape_jobs
SET locked_at = NULL, retry_count = retry_count + 1
SET locked_at = NULL, retry_count = retry_count + 1, queued_at = NOW()
WHERE id = $1
RETURNING CASE WHEN retry_count < $2 THEN retry_count ELSE NULL END",
RETURNING CASE WHEN retry_count <= $2 THEN queued_at ELSE NULL END",
)
.bind(job_id)
.bind(max_retries)
.fetch_one(db_pool)
.await?;
Ok(result.is_some())
Ok(result)
}
/// Find existing unlocked job payloads matching the given target type and candidates.
/// Find existing job payloads matching the given target type and candidates.
///
/// Returns a set of stringified JSON payloads that already exist in the queue,
/// used for deduplication when scheduling new jobs.
/// Returns a set of stringified JSON payloads that already exist in the queue
/// (both locked and unlocked), used for deduplication when scheduling new jobs.
///
/// # Arguments
/// * `target_type` - The target type to filter by
@@ -111,7 +143,7 @@ pub async fn unlock_and_increment_retry(
/// * `db_pool` - PostgreSQL connection pool
///
/// # Returns
/// A `HashSet` of stringified JSON payloads that already have pending jobs
/// A `HashSet` of stringified JSON payloads that already have pending or in-progress jobs
pub async fn find_existing_job_payloads(
target_type: TargetType,
candidate_payloads: &[serde_json::Value],
@@ -119,7 +151,7 @@ pub async fn find_existing_job_payloads(
) -> Result<HashSet<String>> {
let existing_jobs: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT target_payload FROM scrape_jobs
WHERE target_type = $1 AND target_payload = ANY($2) AND locked_at IS NULL",
WHERE target_type = $1 AND target_payload = ANY($2)",
)
.bind(target_type)
.bind(candidate_payloads)
@@ -144,9 +176,9 @@ pub async fn find_existing_job_payloads(
pub async fn batch_insert_jobs(
jobs: &[(serde_json::Value, TargetType, ScrapePriority)],
db_pool: &PgPool,
) -> Result<()> {
) -> Result<Vec<ScrapeJob>> {
if jobs.is_empty() {
return Ok(());
return Ok(Vec::new());
}
let mut target_types: Vec<String> = Vec::with_capacity(jobs.len());
@@ -159,19 +191,20 @@ pub async fn batch_insert_jobs(
priorities.push(format!("{priority:?}"));
}
sqlx::query(
let inserted = sqlx::query_as::<_, ScrapeJob>(
r#"
INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at)
SELECT v.target_type::target_type, v.payload, v.priority::scrape_priority, NOW()
INSERT INTO scrape_jobs (target_type, target_payload, priority, execute_at, queued_at)
SELECT v.target_type::target_type, v.payload, v.priority::scrape_priority, NOW(), NOW()
FROM UNNEST($1::text[], $2::jsonb[], $3::text[])
AS v(target_type, payload, priority)
RETURNING *
"#,
)
.bind(&target_types)
.bind(&payloads)
.bind(&priorities)
.execute(db_pool)
.fetch_all(db_pool)
.await?;
Ok(())
Ok(inserted)
}
+90
View File
@@ -0,0 +1,90 @@
//! Database query functions for user sessions.
use anyhow::Context;
use rand::Rng;
use sqlx::PgPool;
use super::models::UserSession;
use crate::error::Result;
/// Generate a cryptographically random 32-byte hex token.
fn generate_token() -> String {
let bytes: [u8; 32] = rand::rng().random();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Create a new session for a user with the given duration.
pub async fn create_session(
pool: &PgPool,
user_id: i64,
duration: std::time::Duration,
) -> Result<UserSession> {
let token = generate_token();
let duration_secs = duration.as_secs() as i64;
sqlx::query_as::<_, UserSession>(
r#"
INSERT INTO user_sessions (id, user_id, expires_at)
VALUES ($1, $2, now() + make_interval(secs => $3::double precision))
RETURNING *
"#,
)
.bind(&token)
.bind(user_id)
.bind(duration_secs as f64)
.fetch_one(pool)
.await
.context("failed to create session")
}
/// Fetch a session by token, only if it has not expired.
pub async fn get_session(pool: &PgPool, token: &str) -> Result<Option<UserSession>> {
sqlx::query_as::<_, UserSession>(
"SELECT * FROM user_sessions WHERE id = $1 AND expires_at > now()",
)
.bind(token)
.fetch_optional(pool)
.await
.context("failed to get session")
}
/// Update the last-active timestamp for a session.
pub async fn touch_session(pool: &PgPool, token: &str) -> Result<()> {
sqlx::query("UPDATE user_sessions SET last_active_at = now() WHERE id = $1")
.bind(token)
.execute(pool)
.await
.context("failed to touch session")?;
Ok(())
}
/// Delete a session by token.
pub async fn delete_session(pool: &PgPool, token: &str) -> Result<()> {
sqlx::query("DELETE FROM user_sessions WHERE id = $1")
.bind(token)
.execute(pool)
.await
.context("failed to delete session")?;
Ok(())
}
/// Delete all sessions for a user. Returns the number of sessions deleted.
#[allow(dead_code)] // Available for admin user-deletion flow
pub async fn delete_user_sessions(pool: &PgPool, user_id: i64) -> Result<u64> {
let result = sqlx::query("DELETE FROM user_sessions WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await
.context("failed to delete user sessions")?;
Ok(result.rows_affected())
}
/// Delete all expired sessions. Returns the number of sessions cleaned up.
#[allow(dead_code)] // Called by SessionCache::cleanup_expired (not yet wired to periodic task)
pub async fn cleanup_expired(pool: &PgPool) -> Result<u64> {
let result = sqlx::query("DELETE FROM user_sessions WHERE expires_at <= now()")
.execute(pool)
.await
.context("failed to cleanup expired sessions")?;
Ok(result.rows_affected())
}
+86
View File
@@ -0,0 +1,86 @@
//! Database query functions for users.
use anyhow::Context;
use sqlx::PgPool;
use super::models::User;
use crate::error::Result;
/// Insert a new user or update username/avatar on conflict.
pub async fn upsert_user(
pool: &PgPool,
discord_id: i64,
username: &str,
avatar_hash: Option<&str>,
) -> Result<User> {
sqlx::query_as::<_, User>(
r#"
INSERT INTO users (discord_id, discord_username, discord_avatar_hash)
VALUES ($1, $2, $3)
ON CONFLICT (discord_id) DO UPDATE
SET discord_username = EXCLUDED.discord_username,
discord_avatar_hash = EXCLUDED.discord_avatar_hash,
updated_at = now()
RETURNING *
"#,
)
.bind(discord_id)
.bind(username)
.bind(avatar_hash)
.fetch_one(pool)
.await
.context("failed to upsert user")
}
/// Fetch a user by Discord ID.
pub async fn get_user(pool: &PgPool, discord_id: i64) -> Result<Option<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE discord_id = $1")
.bind(discord_id)
.fetch_optional(pool)
.await
.context("failed to get user")
}
/// List all users ordered by creation date (newest first).
pub async fn list_users(pool: &PgPool) -> Result<Vec<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY created_at DESC")
.fetch_all(pool)
.await
.context("failed to list users")
}
/// Set the admin flag for a user, returning the updated user if found.
pub async fn set_admin(pool: &PgPool, discord_id: i64, is_admin: bool) -> Result<Option<User>> {
sqlx::query_as::<_, User>(
r#"
UPDATE users
SET is_admin = $2, updated_at = now()
WHERE discord_id = $1
RETURNING *
"#,
)
.bind(discord_id)
.bind(is_admin)
.fetch_optional(pool)
.await
.context("failed to set admin status")
}
/// Ensure a seed admin exists. Upserts with `is_admin = true` and a placeholder
/// username that will be replaced on first OAuth login.
pub async fn ensure_seed_admin(pool: &PgPool, discord_id: i64) -> Result<User> {
sqlx::query_as::<_, User>(
r#"
INSERT INTO users (discord_id, discord_username, is_admin)
VALUES ($1, 'seed-admin', true)
ON CONFLICT (discord_id) DO UPDATE
SET is_admin = true,
updated_at = now()
RETURNING *
"#,
)
.bind(discord_id)
.fetch_one(pool)
.await
.context("failed to ensure seed admin")
}
+3 -4
View File
@@ -1,5 +1,5 @@
use crate::app::App;
use crate::cli::{Args, ServiceName, determine_enabled_services};
use crate::cli::{Args, ServiceName};
use crate::logging::setup_logging;
use clap::Parser;
use std::process::ExitCode;
@@ -29,9 +29,8 @@ async fn main() -> ExitCode {
// Parse CLI arguments
let args = Args::parse();
// Determine which services should be enabled
let enabled_services: Vec<ServiceName> =
determine_enabled_services(&args).expect("Failed to determine enabled services");
// Always run all services
let enabled_services = ServiceName::all();
// Create and initialize the application
let mut app = App::new().await.expect("Failed to initialize application");
+24 -3
View File
@@ -3,9 +3,11 @@ pub mod scheduler;
pub mod worker;
use crate::banner::BannerApi;
use crate::data::scrape_jobs;
use crate::services::Service;
use crate::state::ReferenceCache;
use crate::status::{ServiceStatus, ServiceStatusRegistry};
use crate::web::ws::ScrapeJobEvent;
use sqlx::PgPool;
use std::sync::Arc;
use tokio::sync::{RwLock, broadcast};
@@ -24,6 +26,7 @@ pub struct ScraperService {
banner_api: Arc<BannerApi>,
reference_cache: Arc<RwLock<ReferenceCache>>,
service_statuses: ServiceStatusRegistry,
job_events_tx: broadcast::Sender<ScrapeJobEvent>,
scheduler_handle: Option<JoinHandle<()>>,
worker_handles: Vec<JoinHandle<()>>,
shutdown_tx: Option<broadcast::Sender<()>>,
@@ -36,12 +39,14 @@ impl ScraperService {
banner_api: Arc<BannerApi>,
reference_cache: Arc<RwLock<ReferenceCache>>,
service_statuses: ServiceStatusRegistry,
job_events_tx: broadcast::Sender<ScrapeJobEvent>,
) -> Self {
Self {
db_pool,
banner_api,
reference_cache,
service_statuses,
job_events_tx,
scheduler_handle: None,
worker_handles: Vec::new(),
shutdown_tx: None,
@@ -49,7 +54,17 @@ impl ScraperService {
}
/// Starts the scheduler and a pool of workers.
pub fn start(&mut self) {
///
/// Force-unlocks any jobs left locked by a previous unclean shutdown before
/// spawning workers, so those jobs re-enter the queue immediately.
pub async fn start(&mut self) {
// Recover jobs left locked by a previous crash/unclean shutdown
match scrape_jobs::force_unlock_all(&self.db_pool).await {
Ok(0) => {}
Ok(count) => warn!(count, "Force-unlocked stale jobs from previous run"),
Err(e) => warn!(error = ?e, "Failed to force-unlock stale jobs"),
}
info!("ScraperService starting");
// Create shutdown channel
@@ -60,6 +75,7 @@ impl ScraperService {
self.db_pool.clone(),
self.banner_api.clone(),
self.reference_cache.clone(),
self.job_events_tx.clone(),
);
let shutdown_rx = shutdown_tx.subscribe();
let scheduler_handle = tokio::spawn(async move {
@@ -70,7 +86,12 @@ impl ScraperService {
let worker_count = 4; // This could be configurable
for i in 0..worker_count {
let worker = Worker::new(i, self.db_pool.clone(), self.banner_api.clone());
let worker = Worker::new(
i,
self.db_pool.clone(),
self.banner_api.clone(),
self.job_events_tx.clone(),
);
let shutdown_rx = shutdown_tx.subscribe();
let worker_handle = tokio::spawn(async move {
worker.run(shutdown_rx).await;
@@ -92,7 +113,7 @@ impl Service for ScraperService {
}
async fn run(&mut self) -> Result<(), anyhow::Error> {
self.start();
self.start().await;
std::future::pending::<()>().await;
Ok(())
}
+21 -3
View File
@@ -5,6 +5,7 @@ use crate::error::Result;
use crate::rmp::RmpClient;
use crate::scraper::jobs::subject::SubjectJob;
use crate::state::ReferenceCache;
use crate::web::ws::{ScrapeJobDto, ScrapeJobEvent};
use serde_json::json;
use sqlx::PgPool;
use std::sync::Arc;
@@ -25,6 +26,7 @@ pub struct Scheduler {
db_pool: PgPool,
banner_api: Arc<BannerApi>,
reference_cache: Arc<RwLock<ReferenceCache>>,
job_events_tx: broadcast::Sender<ScrapeJobEvent>,
}
impl Scheduler {
@@ -32,11 +34,13 @@ impl Scheduler {
db_pool: PgPool,
banner_api: Arc<BannerApi>,
reference_cache: Arc<RwLock<ReferenceCache>>,
job_events_tx: broadcast::Sender<ScrapeJobEvent>,
) -> Self {
Self {
db_pool,
banner_api,
reference_cache,
job_events_tx,
}
}
@@ -74,6 +78,7 @@ impl Scheduler {
let banner_api = self.banner_api.clone();
let cancel_token = cancel_token.clone();
let reference_cache = self.reference_cache.clone();
let job_events_tx = self.job_events_tx.clone();
async move {
tokio::select! {
@@ -99,7 +104,7 @@ impl Scheduler {
tokio::join!(rmp_fut, ref_fut);
if let Err(e) = Self::schedule_jobs_impl(&db_pool, &banner_api).await {
if let Err(e) = Self::schedule_jobs_impl(&db_pool, &banner_api, Some(&job_events_tx)).await {
error!(error = ?e, "Failed to schedule jobs");
}
} => {}
@@ -150,7 +155,11 @@ impl Scheduler {
///
/// This is a static method (not &self) to allow it to be called from spawned tasks.
#[tracing::instrument(skip_all, fields(term))]
async fn schedule_jobs_impl(db_pool: &PgPool, banner_api: &BannerApi) -> Result<()> {
async fn schedule_jobs_impl(
db_pool: &PgPool,
banner_api: &BannerApi,
job_events_tx: Option<&broadcast::Sender<ScrapeJobEvent>>,
) -> Result<()> {
// For now, we will implement a simple baseline scheduling strategy:
// 1. Get a list of all subjects from the Banner API.
// 2. Query existing jobs for all subjects in a single query.
@@ -213,7 +222,16 @@ impl Scheduler {
.map(|(payload, _)| (payload, TargetType::Subject, ScrapePriority::Low))
.collect();
scrape_jobs::batch_insert_jobs(&jobs, db_pool).await?;
let inserted = scrape_jobs::batch_insert_jobs(&jobs, db_pool).await?;
if let Some(tx) = job_events_tx {
inserted.iter().for_each(|job| {
debug!(job_id = job.id, "Emitting JobCreated event");
let _ = tx.send(ScrapeJobEvent::JobCreated {
job: ScrapeJobDto::from(job),
});
});
}
}
debug!("Job scheduling complete");
+64 -7
View File
@@ -1,8 +1,10 @@
use crate::banner::{BannerApi, BannerApiError};
use crate::data::models::ScrapeJob;
use crate::data::models::{ScrapeJob, ScrapeJobStatus};
use crate::data::scrape_jobs;
use crate::error::Result;
use crate::scraper::jobs::{JobError, JobType};
use crate::web::ws::ScrapeJobEvent;
use chrono::Utc;
use sqlx::PgPool;
use std::sync::Arc;
use std::time::Duration;
@@ -10,6 +12,9 @@ use tokio::sync::broadcast;
use tokio::time;
use tracing::{Instrument, debug, error, info, trace, warn};
/// Maximum time a single job is allowed to run before being considered stuck.
const JOB_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// A single worker instance.
///
/// Each worker runs in its own asynchronous task and continuously polls the
@@ -18,14 +23,21 @@ pub struct Worker {
id: usize, // For logging purposes
db_pool: PgPool,
banner_api: Arc<BannerApi>,
job_events_tx: broadcast::Sender<ScrapeJobEvent>,
}
impl Worker {
pub fn new(id: usize, db_pool: PgPool, banner_api: Arc<BannerApi>) -> Self {
pub fn new(
id: usize,
db_pool: PgPool,
banner_api: Arc<BannerApi>,
job_events_tx: broadcast::Sender<ScrapeJobEvent>,
) -> Self {
Self {
id,
db_pool,
banner_api,
job_events_tx,
}
}
@@ -62,13 +74,32 @@ impl Worker {
let max_retries = job.max_retries;
let start = std::time::Instant::now();
// Process the job, racing against shutdown signal
// Emit JobLocked event
let locked_at = Utc::now().to_rfc3339();
debug!(job_id, "Emitting JobLocked event");
let _ = self.job_events_tx.send(ScrapeJobEvent::JobLocked {
id: job_id,
locked_at,
status: ScrapeJobStatus::Processing,
});
// Process the job, racing against shutdown signal and timeout
let process_result = tokio::select! {
_ = shutdown_rx.recv() => {
self.handle_shutdown_during_processing(job_id).await;
break;
}
result = self.process_job(job) => result
result = async {
match time::timeout(JOB_TIMEOUT, self.process_job(job)).await {
Ok(result) => result,
Err(_elapsed) => {
Err(JobError::Recoverable(anyhow::anyhow!(
"job timed out after {}s",
JOB_TIMEOUT.as_secs()
)))
}
}
} => result
};
let duration = start.elapsed();
@@ -130,7 +161,11 @@ impl Worker {
scrape_jobs::unlock_job(job_id, &self.db_pool).await
}
async fn unlock_and_increment_retry(&self, job_id: i32, max_retries: i32) -> Result<bool> {
async fn unlock_and_increment_retry(
&self,
job_id: i32,
max_retries: i32,
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
scrape_jobs::unlock_and_increment_retry(job_id, max_retries, &self.db_pool).await
}
@@ -175,6 +210,10 @@ impl Worker {
if let Err(e) = self.delete_job(job_id).await {
error!(worker_id = self.id, job_id, error = ?e, "Failed to delete completed job");
}
debug!(job_id, "Emitting JobCompleted event");
let _ = self
.job_events_tx
.send(ScrapeJobEvent::JobCompleted { id: job_id });
}
Err(JobError::Recoverable(e)) => {
self.handle_recoverable_error(job_id, retry_count, max_retries, e, duration)
@@ -191,6 +230,10 @@ impl Worker {
if let Err(e) = self.delete_job(job_id).await {
error!(worker_id = self.id, job_id, error = ?e, "Failed to delete corrupted job");
}
debug!(job_id, "Emitting JobDeleted event");
let _ = self
.job_events_tx
.send(ScrapeJobEvent::JobDeleted { id: job_id });
}
}
}
@@ -233,7 +276,7 @@ impl Worker {
// Atomically unlock and increment retry count, checking if retry is allowed
match self.unlock_and_increment_retry(job_id, max_retries).await {
Ok(can_retry) if can_retry => {
Ok(Some(queued_at)) => {
debug!(
worker_id = self.id,
job_id,
@@ -241,8 +284,15 @@ impl Worker {
remaining_retries = remaining_retries,
"Job unlocked for retry"
);
debug!(job_id, "Emitting JobRetried event");
let _ = self.job_events_tx.send(ScrapeJobEvent::JobRetried {
id: job_id,
retry_count: next_attempt,
queued_at: queued_at.to_rfc3339(),
status: ScrapeJobStatus::Pending,
});
}
Ok(_) => {
Ok(None) => {
// Max retries exceeded (detected atomically)
error!(
worker_id = self.id,
@@ -256,6 +306,13 @@ impl Worker {
if let Err(e) = self.delete_job(job_id).await {
error!(worker_id = self.id, job_id, error = ?e, "Failed to delete failed job");
}
debug!(job_id, "Emitting JobExhausted and JobDeleted events");
let _ = self
.job_events_tx
.send(ScrapeJobEvent::JobExhausted { id: job_id });
let _ = self
.job_events_tx
.send(ScrapeJobEvent::JobDeleted { id: job_id });
}
Err(e) => {
error!(worker_id = self.id, job_id, error = ?e, "Failed to unlock and increment retry count");
+5 -2
View File
@@ -1,6 +1,7 @@
use super::Service;
use crate::state::AppState;
use crate::status::ServiceStatus;
use crate::web::auth::AuthConfig;
use crate::web::create_router;
use std::net::SocketAddr;
use tokio::net::TcpListener;
@@ -11,14 +12,16 @@ use tracing::{info, trace, warn};
pub struct WebService {
port: u16,
app_state: AppState,
auth_config: AuthConfig,
shutdown_tx: Option<broadcast::Sender<()>>,
}
impl WebService {
pub fn new(port: u16, app_state: AppState) -> Self {
pub fn new(port: u16, app_state: AppState, auth_config: AuthConfig) -> Self {
Self {
port,
app_state,
auth_config,
shutdown_tx: None,
}
}
@@ -58,7 +61,7 @@ impl Service for WebService {
async fn run(&mut self) -> Result<(), anyhow::Error> {
// Create the main router with Banner API routes
let app = create_router(self.app_state.clone());
let app = create_router(self.app_state.clone(), self.auth_config.clone());
let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
+15 -1
View File
@@ -4,11 +4,13 @@ use crate::banner::BannerApi;
use crate::banner::Course;
use crate::data::models::ReferenceData;
use crate::status::ServiceStatusRegistry;
use crate::web::session_cache::{OAuthStateStore, SessionCache};
use crate::web::ws::ScrapeJobEvent;
use anyhow::Result;
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::{RwLock, broadcast};
/// In-memory cache for reference data (code→description lookups).
///
@@ -72,18 +74,30 @@ pub struct AppState {
pub db_pool: PgPool,
pub service_statuses: ServiceStatusRegistry,
pub reference_cache: Arc<RwLock<ReferenceCache>>,
pub session_cache: SessionCache,
pub oauth_state_store: OAuthStateStore,
pub scrape_job_tx: broadcast::Sender<ScrapeJobEvent>,
}
impl AppState {
pub fn new(banner_api: Arc<BannerApi>, db_pool: PgPool) -> Self {
let (scrape_job_tx, _) = broadcast::channel(64);
Self {
session_cache: SessionCache::new(db_pool.clone()),
oauth_state_store: OAuthStateStore::new(),
banner_api,
db_pool,
service_statuses: ServiceStatusRegistry::new(),
reference_cache: Arc::new(RwLock::new(ReferenceCache::new())),
scrape_job_tx,
}
}
/// Subscribe to scrape job lifecycle events.
pub fn scrape_job_events(&self) -> broadcast::Receiver<ScrapeJobEvent> {
self.scrape_job_tx.subscribe()
}
/// Initialize the reference cache from the database.
pub async fn load_reference_cache(&self) -> Result<()> {
let entries = crate::data::reference::get_all(&self.db_pool).await?;
+269
View File
@@ -0,0 +1,269 @@
//! Admin API handlers.
//!
//! All endpoints require the `AdminUser` extractor, returning 401/403 as needed.
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::data::models::User;
use crate::state::AppState;
use crate::web::extractors::AdminUser;
/// `GET /api/admin/status` — Enhanced system status for admins.
pub async fn admin_status(
AdminUser(_user): AdminUser,
State(state): State<AppState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to count users");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to count users"})),
)
})?;
let (session_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM user_sessions WHERE expires_at > now()")
.fetch_one(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to count sessions");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to count sessions"})),
)
})?;
let course_count = state.get_course_count().await.map_err(|e| {
tracing::error!(error = %e, "failed to count courses");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to count courses"})),
)
})?;
let (scrape_job_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scrape_jobs")
.fetch_one(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to count scrape jobs");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to count scrape jobs"})),
)
})?;
let services: Vec<Value> = state
.service_statuses
.all()
.into_iter()
.map(|(name, status)| {
json!({
"name": name,
"status": status,
})
})
.collect();
Ok(Json(json!({
"userCount": user_count,
"sessionCount": session_count,
"courseCount": course_count,
"scrapeJobCount": scrape_job_count,
"services": services,
})))
}
/// `GET /api/admin/users` — List all users.
pub async fn list_users(
AdminUser(_user): AdminUser,
State(state): State<AppState>,
) -> Result<Json<Vec<User>>, (StatusCode, Json<Value>)> {
let users = crate::data::users::list_users(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to list users");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to list users"})),
)
})?;
Ok(Json(users))
}
#[derive(Deserialize)]
pub struct SetAdminBody {
is_admin: bool,
}
/// `PUT /api/admin/users/{discord_id}/admin` — Set admin status for a user.
pub async fn set_user_admin(
AdminUser(_user): AdminUser,
State(state): State<AppState>,
Path(discord_id): Path<i64>,
Json(body): Json<SetAdminBody>,
) -> Result<Json<User>, (StatusCode, Json<Value>)> {
let user = crate::data::users::set_admin(&state.db_pool, discord_id, body.is_admin)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to set admin status");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to set admin status"})),
)
})?
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({"error": "user not found"})),
)
})?;
state.session_cache.evict_user(discord_id);
Ok(Json(user))
}
/// `GET /api/admin/scrape-jobs` — List scrape jobs.
pub async fn list_scrape_jobs(
AdminUser(_user): AdminUser,
State(state): State<AppState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let rows = sqlx::query_as::<_, crate::data::models::ScrapeJob>(
"SELECT * FROM scrape_jobs ORDER BY priority DESC, execute_at ASC LIMIT 100",
)
.fetch_all(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to list scrape jobs");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to list scrape jobs"})),
)
})?;
let jobs: Vec<Value> = rows
.iter()
.map(|j| {
json!({
"id": j.id,
"targetType": format!("{:?}", j.target_type),
"targetPayload": j.target_payload,
"priority": format!("{:?}", j.priority),
"executeAt": j.execute_at.to_rfc3339(),
"createdAt": j.created_at.to_rfc3339(),
"lockedAt": j.locked_at.map(|t| t.to_rfc3339()),
"retryCount": j.retry_count,
"maxRetries": j.max_retries,
"queuedAt": j.queued_at.to_rfc3339(),
"status": j.status(),
})
})
.collect();
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<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
.map_err(|e| {
tracing::error!(error = %e, "failed to list audit log");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to 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| {
json!({
"id": a.id,
"courseId": a.course_id,
"timestamp": a.timestamp.to_rfc3339(),
"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();
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)
}
+114 -19
View File
@@ -1,14 +1,18 @@
//! Embedded assets for the web frontend
//! Embedded assets for the web frontend.
//!
//! This module handles serving static assets that are embedded into the binary
//! at compile time using rust-embed.
//! Serves static assets embedded into the binary at compile time using rust-embed.
//! Supports content negotiation for pre-compressed variants (.br, .gz, .zst)
//! generated at build time by `web/scripts/compress-assets.ts`.
use axum::http::{HeaderMap, HeaderValue, header};
use dashmap::DashMap;
use rapidhash::v3::rapidhash_v3;
use rust_embed::RustEmbed;
use std::fmt;
use std::sync::LazyLock;
use super::encoding::{COMPRESSION_MIN_SIZE, ContentEncoding, parse_accepted_encodings};
/// Embedded web assets from the dist directory
#[derive(RustEmbed)]
#[folder = "web/dist/"]
@@ -21,17 +25,15 @@ pub struct WebAssets;
pub struct AssetHash(u64);
impl AssetHash {
/// Create a new AssetHash from u64 value
pub fn new(hash: u64) -> Self {
Self(hash)
}
/// Get the hash as a hex string
pub fn to_hex(&self) -> String {
format!("{:016x}", self.0)
}
/// Get the hash as a quoted hex string
/// Get the hash as a quoted hex string (for ETag headers)
pub fn quoted(&self) -> String {
format!("\"{}\"", self.to_hex())
}
@@ -51,12 +53,8 @@ pub struct AssetMetadata {
}
impl AssetMetadata {
/// Check if the etag matches the asset hash
pub fn etag_matches(&self, etag: &str) -> bool {
// Remove quotes if present (ETags are typically quoted)
let etag = etag.trim_matches('"');
// ETags generated from u64 hex should be 16 characters
etag.len() == 16
&& u64::from_str_radix(etag, 16)
.map(|parsed| parsed == self.hash.0)
@@ -68,28 +66,125 @@ impl AssetMetadata {
static ASSET_CACHE: LazyLock<DashMap<String, AssetMetadata>> = LazyLock::new(DashMap::new);
/// Get cached asset metadata for a file path, caching on-demand
/// Returns AssetMetadata containing MIME type and RapidHash hash
pub fn get_asset_metadata_cached(path: &str, content: &[u8]) -> AssetMetadata {
// Check cache first
if let Some(cached) = ASSET_CACHE.get(path) {
return cached.value().clone();
}
// Calculate MIME type
let mime_type = mime_guess::from_path(path)
.first()
.map(|mime| mime.to_string());
// Calculate RapidHash hash (using u64 native output size)
let hash_value = rapidhash_v3(content);
let hash = AssetHash::new(hash_value);
let hash = AssetHash::new(rapidhash_v3(content));
let metadata = AssetMetadata { mime_type, hash };
// Only cache if we haven't exceeded the limit
if ASSET_CACHE.len() < 1000 {
ASSET_CACHE.insert(path.to_string(), metadata.clone());
}
metadata
}
/// Set appropriate `Cache-Control` header based on the asset path.
///
/// SvelteKit outputs fingerprinted assets under `_app/immutable/` which are
/// safe to cache indefinitely. Other assets get shorter cache durations.
fn set_cache_control(headers: &mut HeaderMap, path: &str) {
let cache_control = if path.contains("immutable/") {
// SvelteKit fingerprinted assets — cache forever
"public, max-age=31536000, immutable"
} else if path == "index.html" || path.ends_with(".html") {
"public, max-age=300"
} else {
match path.rsplit_once('.').map(|(_, ext)| ext) {
Some("css" | "js") => "public, max-age=86400",
Some("png" | "jpg" | "jpeg" | "gif" | "svg" | "ico") => "public, max-age=2592000",
_ => "public, max-age=3600",
}
};
if let Ok(value) = HeaderValue::from_str(cache_control) {
headers.insert(header::CACHE_CONTROL, value);
}
}
/// Serve an embedded asset with content encoding negotiation.
///
/// Tries pre-compressed variants (.br, .gz, .zst) in the order preferred by
/// the client's `Accept-Encoding` header, falling back to the uncompressed
/// original. Returns `None` if the asset doesn't exist at all.
pub fn try_serve_asset_with_encoding(
path: &str,
request_headers: &HeaderMap,
) -> Option<axum::response::Response> {
use axum::response::IntoResponse;
let asset_path = path.strip_prefix('/').unwrap_or(path);
// Get the uncompressed original first (for metadata: MIME type, ETag)
let original = WebAssets::get(asset_path)?;
let metadata = get_asset_metadata_cached(asset_path, &original.data);
// Check ETag for conditional requests (304 Not Modified)
if let Some(etag) = request_headers.get(header::IF_NONE_MATCH)
&& etag.to_str().is_ok_and(|s| metadata.etag_matches(s))
{
return Some(axum::http::StatusCode::NOT_MODIFIED.into_response());
}
let mime_type = metadata
.mime_type
.unwrap_or_else(|| "application/octet-stream".to_string());
// Only attempt pre-compressed variants for files above the compression
// threshold — the build script skips smaller files too.
let accepted_encodings = if original.data.len() >= COMPRESSION_MIN_SIZE {
parse_accepted_encodings(request_headers)
} else {
vec![ContentEncoding::Identity]
};
for encoding in &accepted_encodings {
if *encoding == ContentEncoding::Identity {
continue;
}
let compressed_path = format!("{}{}", asset_path, encoding.extension());
if let Some(compressed) = WebAssets::get(&compressed_path) {
let mut response_headers = HeaderMap::new();
if let Ok(ct) = HeaderValue::from_str(&mime_type) {
response_headers.insert(header::CONTENT_TYPE, ct);
}
if let Some(ce) = encoding.header_value() {
response_headers.insert(header::CONTENT_ENCODING, ce);
}
if let Ok(etag_val) = HeaderValue::from_str(&metadata.hash.quoted()) {
response_headers.insert(header::ETAG, etag_val);
}
// Vary so caches distinguish by encoding
response_headers.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
set_cache_control(&mut response_headers, asset_path);
return Some(
(
axum::http::StatusCode::OK,
response_headers,
compressed.data,
)
.into_response(),
);
}
}
// No compressed variant found — serve uncompressed original
let mut response_headers = HeaderMap::new();
if let Ok(ct) = HeaderValue::from_str(&mime_type) {
response_headers.insert(header::CONTENT_TYPE, ct);
}
if let Ok(etag_val) = HeaderValue::from_str(&metadata.hash.quoted()) {
response_headers.insert(header::ETAG, etag_val);
}
set_cache_control(&mut response_headers, asset_path);
Some((axum::http::StatusCode::OK, response_headers, original.data).into_response())
}
+300
View File
@@ -0,0 +1,300 @@
//! Discord OAuth2 authentication handlers.
//!
//! Provides login, callback, logout, and session introspection endpoints
//! for Discord OAuth2 authentication flow.
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Json, Redirect, Response};
use serde::Deserialize;
use serde_json::{Value, json};
use std::time::Duration;
use tracing::{error, info, warn};
use crate::state::AppState;
/// OAuth configuration passed as an Axum Extension.
#[derive(Clone)]
pub struct AuthConfig {
pub client_id: String,
pub client_secret: String,
/// Optional base URL override (e.g. "https://banner.xevion.dev").
/// When `None`, the redirect URI is derived from the request's Origin/Host header.
pub redirect_base: Option<String>,
}
const CALLBACK_PATH: &str = "/api/auth/callback";
/// Derive the origin (scheme + host + port) the user's browser is actually on.
///
/// Priority:
/// 1. Configured `redirect_base` (production override)
/// 2. `Referer` header — preserves the real browser origin even through
/// reverse proxies that rewrite `Host` (e.g. Vite dev proxy with
/// `changeOrigin: true`)
/// 3. `Origin` header (present on POST / CORS requests)
/// 4. `Host` header (last resort, may be rewritten by proxies)
fn resolve_origin(auth_config: &AuthConfig, headers: &HeaderMap) -> String {
if let Some(base) = &auth_config.redirect_base {
return base.trim_end_matches('/').to_owned();
}
// Referer carries the full browser URL; extract just the origin.
if let Some(referer) = headers.get(header::REFERER).and_then(|v| v.to_str().ok())
&& let Ok(parsed) = url::Url::parse(referer)
{
let origin = parsed.origin().unicode_serialization();
if origin != "null" {
return origin;
}
}
if let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) {
return origin.trim_end_matches('/').to_owned();
}
if let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok()) {
return format!("http://{host}");
}
"http://localhost:8080".to_owned()
}
#[derive(Deserialize)]
pub struct CallbackParams {
code: String,
state: String,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
}
#[derive(Deserialize)]
struct DiscordUser {
id: String,
username: String,
avatar: Option<String>,
}
/// Extract the `session` cookie value from request headers.
fn extract_session_token(headers: &HeaderMap) -> Option<String> {
headers
.get(header::COOKIE)?
.to_str()
.ok()?
.split(';')
.find_map(|cookie| {
let cookie = cookie.trim();
cookie.strip_prefix("session=").map(|v| v.to_owned())
})
}
/// Build a `Set-Cookie` header value for the session cookie.
fn session_cookie(token: &str, max_age: i64, secure: bool) -> String {
let mut cookie = format!("session={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}");
if secure {
cookie.push_str("; Secure");
}
cookie
}
/// `GET /api/auth/login` — Redirect to Discord OAuth2 authorization page.
pub async fn auth_login(
State(state): State<AppState>,
Extension(auth_config): Extension<AuthConfig>,
headers: HeaderMap,
) -> Redirect {
let origin = resolve_origin(&auth_config, &headers);
let redirect_uri = format!("{origin}{CALLBACK_PATH}");
let csrf_state = state.oauth_state_store.generate(origin);
let redirect_uri_encoded = urlencoding::encode(&redirect_uri);
let url = format!(
"https://discord.com/oauth2/authorize\
?client_id={}\
&redirect_uri={redirect_uri_encoded}\
&response_type=code\
&scope=identify\
&state={csrf_state}",
auth_config.client_id,
);
Redirect::temporary(&url)
}
/// `GET /api/auth/callback` — Handle Discord OAuth2 callback.
pub async fn auth_callback(
State(state): State<AppState>,
Extension(auth_config): Extension<AuthConfig>,
Query(params): Query<CallbackParams>,
) -> Result<Response, (StatusCode, Json<Value>)> {
// 1. Validate CSRF state and recover the origin used during login
let origin = state
.oauth_state_store
.validate(&params.state)
.ok_or_else(|| {
warn!("OAuth callback with invalid CSRF state");
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": "Invalid OAuth state" })),
)
})?;
// 2. Exchange authorization code for access token
let redirect_uri = format!("{origin}{CALLBACK_PATH}");
let client = reqwest::Client::new();
let token_response = client
.post("https://discord.com/api/oauth2/token")
.form(&[
("client_id", auth_config.client_id.as_str()),
("client_secret", auth_config.client_secret.as_str()),
("grant_type", "authorization_code"),
("code", params.code.as_str()),
("redirect_uri", redirect_uri.as_str()),
])
.send()
.await
.map_err(|e| {
error!(error = %e, "failed to exchange OAuth code for token");
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "Failed to exchange code with Discord" })),
)
})?;
if !token_response.status().is_success() {
let status = token_response.status();
let body = token_response.text().await.unwrap_or_default();
error!(%status, %body, "Discord token exchange returned error");
return Err((
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "Discord token exchange failed" })),
));
}
let token_data: TokenResponse = token_response.json().await.map_err(|e| {
error!(error = %e, "failed to parse Discord token response");
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "Invalid token response from Discord" })),
)
})?;
// 3. Fetch Discord user profile
let discord_user: DiscordUser = client
.get("https://discord.com/api/users/@me")
.bearer_auth(&token_data.access_token)
.send()
.await
.map_err(|e| {
error!(error = %e, "failed to fetch Discord user profile");
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "Failed to fetch Discord profile" })),
)
})?
.json()
.await
.map_err(|e| {
error!(error = %e, "failed to parse Discord user profile");
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "Invalid user profile from Discord" })),
)
})?;
let discord_id: i64 = discord_user.id.parse().map_err(|_| {
error!(id = %discord_user.id, "Discord user ID is not a valid i64");
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": "Invalid Discord user ID" })),
)
})?;
// 4. Upsert user
let user = crate::data::users::upsert_user(
&state.db_pool,
discord_id,
&discord_user.username,
discord_user.avatar.as_deref(),
)
.await
.map_err(|e| {
error!(error = %e, "failed to upsert user");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Database error" })),
)
})?;
info!(discord_id, username = %user.discord_username, "user authenticated via OAuth");
// 5. Create session
let session = crate::data::sessions::create_session(
&state.db_pool,
discord_id,
Duration::from_secs(7 * 24 * 3600),
)
.await
.map_err(|e| {
error!(error = %e, "failed to create session");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "Failed to create session" })),
)
})?;
// 6. Build response with session cookie
let secure = redirect_uri.starts_with("https://");
let cookie = session_cookie(&session.id, 604800, secure);
let redirect_to = if user.is_admin { "/admin" } else { "/" };
Ok((
[(header::SET_COOKIE, cookie)],
Redirect::temporary(redirect_to),
)
.into_response())
}
/// `POST /api/auth/logout` — Destroy the current session.
pub async fn auth_logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(token) = extract_session_token(&headers) {
if let Err(e) = crate::data::sessions::delete_session(&state.db_pool, &token).await {
warn!(error = %e, "failed to delete session from database");
}
state.session_cache.evict(&token);
}
let cookie = session_cookie("", 0, false);
(
StatusCode::OK,
[(header::SET_COOKIE, cookie)],
Json(json!({ "ok": true })),
)
.into_response()
}
/// `GET /api/auth/me` — Return the current authenticated user's info.
pub async fn auth_me(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, StatusCode> {
let token = extract_session_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
let user = state
.session_cache
.get_user(&token)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(Json(json!({
"discordId": user.discord_id.to_string(),
"username": user.discord_username,
"avatarHash": user.discord_avatar_hash,
"isAdmin": user.is_admin,
})))
}
+196
View File
@@ -0,0 +1,196 @@
//! Content encoding negotiation for pre-compressed asset serving.
//!
//! Parses Accept-Encoding headers with quality values and returns
//! supported encodings in priority order for content negotiation.
use axum::http::{HeaderMap, HeaderValue, header};
/// Minimum size threshold for compression (bytes).
///
/// Must match `MIN_SIZE` in `web/scripts/compress-assets.ts`.
pub const COMPRESSION_MIN_SIZE: usize = 512;
/// Supported content encodings in priority order (best compression first).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContentEncoding {
Zstd,
Brotli,
Gzip,
Identity,
}
impl ContentEncoding {
/// File extension suffix for pre-compressed variant lookup.
#[inline]
pub fn extension(&self) -> &'static str {
match self {
Self::Zstd => ".zst",
Self::Brotli => ".br",
Self::Gzip => ".gz",
Self::Identity => "",
}
}
/// `Content-Encoding` header value, or `None` for identity.
#[inline]
pub fn header_value(&self) -> Option<HeaderValue> {
match self {
Self::Zstd => Some(HeaderValue::from_static("zstd")),
Self::Brotli => Some(HeaderValue::from_static("br")),
Self::Gzip => Some(HeaderValue::from_static("gzip")),
Self::Identity => None,
}
}
/// Default priority when quality values are equal (higher = better).
#[inline]
fn default_priority(&self) -> u8 {
match self {
Self::Zstd => 4,
Self::Brotli => 3,
Self::Gzip => 2,
Self::Identity => 1,
}
}
}
/// Parse `Accept-Encoding` header and return supported encodings in priority order.
///
/// Supports quality values: `Accept-Encoding: gzip;q=0.8, br;q=1.0, zstd`
/// When quality values are equal: zstd > brotli > gzip > identity.
/// Encodings with `q=0` are excluded.
pub fn parse_accepted_encodings(headers: &HeaderMap) -> Vec<ContentEncoding> {
let Some(accept) = headers
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
else {
return vec![ContentEncoding::Identity];
};
let mut encodings: Vec<(ContentEncoding, f32)> = Vec::new();
for part in accept.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (encoding_str, quality) = if let Some((enc, params)) = part.split_once(';') {
let q = params
.split(';')
.find_map(|p| p.trim().strip_prefix("q="))
.and_then(|q| q.parse::<f32>().ok())
.unwrap_or(1.0);
(enc.trim(), q)
} else {
(part, 1.0)
};
if quality == 0.0 {
continue;
}
let encoding = match encoding_str.to_lowercase().as_str() {
"zstd" => ContentEncoding::Zstd,
"br" | "brotli" => ContentEncoding::Brotli,
"gzip" | "x-gzip" => ContentEncoding::Gzip,
"*" => ContentEncoding::Gzip,
"identity" => ContentEncoding::Identity,
_ => continue,
};
encodings.push((encoding, quality));
}
// Sort by quality (desc), then default priority (desc)
encodings.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.0.default_priority().cmp(&a.0.default_priority()))
});
if encodings.is_empty() {
vec![ContentEncoding::Identity]
} else {
encodings.into_iter().map(|(e, _)| e).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_all_encodings() {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT_ENCODING, "gzip, br, zstd".parse().unwrap());
let encodings = parse_accepted_encodings(&headers);
assert_eq!(encodings[0], ContentEncoding::Zstd);
assert_eq!(encodings[1], ContentEncoding::Brotli);
assert_eq!(encodings[2], ContentEncoding::Gzip);
}
#[test]
fn test_parse_with_quality_values() {
let mut headers = HeaderMap::new();
headers.insert(
header::ACCEPT_ENCODING,
"gzip;q=1.0, br;q=0.5, zstd;q=0.8".parse().unwrap(),
);
let encodings = parse_accepted_encodings(&headers);
assert_eq!(encodings[0], ContentEncoding::Gzip);
assert_eq!(encodings[1], ContentEncoding::Zstd);
assert_eq!(encodings[2], ContentEncoding::Brotli);
}
#[test]
fn test_no_header_returns_identity() {
let headers = HeaderMap::new();
let encodings = parse_accepted_encodings(&headers);
assert_eq!(encodings, vec![ContentEncoding::Identity]);
}
#[test]
fn test_disabled_encoding_excluded() {
let mut headers = HeaderMap::new();
headers.insert(
header::ACCEPT_ENCODING,
"zstd;q=0, br, gzip".parse().unwrap(),
);
let encodings = parse_accepted_encodings(&headers);
assert_eq!(encodings[0], ContentEncoding::Brotli);
assert_eq!(encodings[1], ContentEncoding::Gzip);
assert!(!encodings.contains(&ContentEncoding::Zstd));
}
#[test]
fn test_real_chrome_header() {
let mut headers = HeaderMap::new();
headers.insert(
header::ACCEPT_ENCODING,
"gzip, deflate, br, zstd".parse().unwrap(),
);
assert_eq!(parse_accepted_encodings(&headers)[0], ContentEncoding::Zstd);
}
#[test]
fn test_extensions() {
assert_eq!(ContentEncoding::Zstd.extension(), ".zst");
assert_eq!(ContentEncoding::Brotli.extension(), ".br");
assert_eq!(ContentEncoding::Gzip.extension(), ".gz");
assert_eq!(ContentEncoding::Identity.extension(), "");
}
#[test]
fn test_header_values() {
assert_eq!(
ContentEncoding::Zstd.header_value().unwrap(),
HeaderValue::from_static("zstd")
);
assert_eq!(
ContentEncoding::Brotli.header_value().unwrap(),
HeaderValue::from_static("br")
);
assert!(ContentEncoding::Identity.header_value().is_none());
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Axum extractors for authentication and authorization.
use axum::extract::FromRequestParts;
use axum::http::{StatusCode, header};
use axum::response::Json;
use http::request::Parts;
use serde_json::json;
use crate::data::models::User;
use crate::state::AppState;
/// Extractor that resolves the session cookie to an authenticated [`User`].
///
/// Returns 401 if no valid session cookie is present.
pub struct AuthUser(pub User);
impl FromRequestParts<AppState> for AuthUser {
type Rejection = (StatusCode, Json<serde_json::Value>);
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = parts
.headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies
.split(';')
.find_map(|c| c.trim().strip_prefix("session=").map(|v| v.to_owned()))
})
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "unauthorized", "message": "No session cookie"})),
)
})?;
let user = state.session_cache.get_user(&token).await.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "unauthorized", "message": "Invalid or expired session"})),
)
})?;
Ok(AuthUser(user))
}
}
/// Extractor that requires an authenticated admin user.
///
/// Returns 401 if not authenticated, 403 if not admin.
pub struct AdminUser(pub User);
impl FromRequestParts<AppState> for AdminUser {
type Rejection = (StatusCode, Json<serde_json::Value>);
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let AuthUser(user) = AuthUser::from_request_parts(parts, state).await?;
if !user.is_admin {
return Err((
StatusCode::FORBIDDEN,
Json(json!({"error": "forbidden", "message": "Admin access required"})),
));
}
Ok(AdminUser(user))
}
}
+7
View File
@@ -1,7 +1,14 @@
//! Web API module for the banner application.
pub mod admin;
#[cfg(feature = "embed-assets")]
pub mod assets;
pub mod auth;
#[cfg(feature = "embed-assets")]
pub mod encoding;
pub mod extractors;
pub mod routes;
pub mod session_cache;
pub mod ws;
pub use routes::*;
+183 -109
View File
@@ -1,20 +1,22 @@
//! Web API endpoints for Banner bot monitoring and metrics.
use axum::{
Router,
Extension, Router,
body::Body,
extract::{Path, Query, Request, State},
http::StatusCode as AxumStatusCode,
response::{Json, Response},
routing::get,
routing::{get, post, put},
};
use crate::web::admin;
use crate::web::auth::{self, AuthConfig};
use crate::web::ws;
#[cfg(feature = "embed-assets")]
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, Uri},
response::{Html, IntoResponse},
http::{HeaderMap, StatusCode, Uri},
response::IntoResponse,
};
#[cfg(feature = "embed-assets")]
use http::header;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{collections::BTreeMap, time::Duration};
@@ -24,51 +26,17 @@ use crate::state::AppState;
use crate::status::ServiceStatus;
#[cfg(not(feature = "embed-assets"))]
use tower_http::cors::{Any, CorsLayer};
use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer};
use tower_http::{
classify::ServerErrorsFailureClass, compression::CompressionLayer, timeout::TimeoutLayer,
trace::TraceLayer,
};
use tracing::{Span, debug, trace, warn};
#[cfg(feature = "embed-assets")]
use crate::web::assets::{WebAssets, get_asset_metadata_cached};
/// Set appropriate caching headers based on asset type
#[cfg(feature = "embed-assets")]
fn set_caching_headers(response: &mut Response, path: &str, etag: &str) {
let headers = response.headers_mut();
// Set ETag
if let Ok(etag_value) = HeaderValue::from_str(etag) {
headers.insert(header::ETAG, etag_value);
}
// Set Cache-Control based on asset type
let cache_control = if path.starts_with("assets/") {
// Static assets with hashed filenames - long-term cache
"public, max-age=31536000, immutable"
} else if path == "index.html" {
// HTML files - short-term cache
"public, max-age=300"
} else {
match path.split_once('.').map(|(_, extension)| extension) {
Some(ext) => match ext {
// CSS/JS files - medium-term cache
"css" | "js" => "public, max-age=86400",
// Images - long-term cache
"png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" => "public, max-age=2592000",
// Default for other files
_ => "public, max-age=3600",
},
// Default for files without an extension
None => "public, max-age=3600",
}
};
if let Ok(cache_control_value) = HeaderValue::from_str(cache_control) {
headers.insert(header::CACHE_CONTROL, cache_control_value);
}
}
use crate::web::assets::try_serve_asset_with_encoding;
/// Creates the web server router
pub fn create_router(app_state: AppState) -> Router {
pub fn create_router(app_state: AppState, auth_config: AuthConfig) -> Router {
let api_router = Router::new()
.route("/health", get(health))
.route("/status", get(status))
@@ -78,9 +46,32 @@ pub fn create_router(app_state: AppState) -> Router {
.route("/terms", get(get_terms))
.route("/subjects", get(get_subjects))
.route("/reference/{category}", get(get_reference))
.with_state(app_state.clone());
let auth_router = Router::new()
.route("/auth/login", get(auth::auth_login))
.route("/auth/callback", get(auth::auth_callback))
.route("/auth/logout", post(auth::auth_logout))
.route("/auth/me", get(auth::auth_me))
.layer(Extension(auth_config))
.with_state(app_state.clone());
let admin_router = Router::new()
.route("/admin/status", get(admin::admin_status))
.route("/admin/users", get(admin::list_users))
.route(
"/admin/users/{discord_id}/admin",
put(admin::set_user_admin),
)
.route("/admin/scrape-jobs", get(admin::list_scrape_jobs))
.route("/admin/scrape-jobs/ws", get(ws::scrape_jobs_ws))
.route("/admin/audit-log", get(admin::list_audit_log))
.with_state(app_state);
let mut router = Router::new().nest("/api", api_router);
let mut router = Router::new()
.nest("/api", api_router)
.nest("/api", auth_router)
.nest("/api", admin_router);
// When embed-assets feature is enabled, serve embedded static assets
#[cfg(feature = "embed-assets")]
@@ -100,6 +91,13 @@ pub fn create_router(app_state: AppState) -> Router {
}
router.layer((
// Compress API responses (gzip/brotli/zstd). Pre-compressed static
// assets already have Content-Encoding set, so tower-http skips them.
CompressionLayer::new()
.zstd(true)
.br(true)
.gzip(true)
.quality(tower_http::CompressionLevel::Fastest),
TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| {
tracing::debug_span!("request", path = request.uri().path())
@@ -146,71 +144,35 @@ pub fn create_router(app_state: AppState) -> Router {
))
}
/// Handler that extracts request information for caching
/// SPA fallback handler with content encoding negotiation.
///
/// Serves embedded static assets with pre-compressed variants when available,
/// falling back to `index.html` for SPA client-side routing.
#[cfg(feature = "embed-assets")]
async fn fallback(request: Request) -> Response {
async fn fallback(request: Request) -> axum::response::Response {
let uri = request.uri().clone();
let headers = request.headers().clone();
handle_spa_fallback_with_headers(uri, headers).await
handle_spa_fallback(uri, headers).await
}
/// Handles SPA routing by serving index.html for non-API, non-asset requests
/// This version includes HTTP caching headers and ETag support
#[cfg(feature = "embed-assets")]
async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap) -> Response {
let path = uri.path().trim_start_matches('/');
if let Some(content) = WebAssets::get(path) {
// Get asset metadata (MIME type and hash) with caching
let metadata = get_asset_metadata_cached(path, &content.data);
// Check if client has a matching ETag (conditional request)
if let Some(etag) = request_headers.get(header::IF_NONE_MATCH)
&& etag.to_str().is_ok_and(|s| metadata.etag_matches(s))
{
return StatusCode::NOT_MODIFIED.into_response();
}
// Use cached MIME type, only set Content-Type if we have a valid MIME type
let mut response = (
[(
header::CONTENT_TYPE,
// For unknown types, set to application/octet-stream
metadata
.mime_type
.unwrap_or("application/octet-stream".to_string()),
)],
content.data,
)
.into_response();
// Set caching headers
set_caching_headers(&mut response, path, &metadata.hash.quoted());
async fn handle_spa_fallback(uri: Uri, request_headers: HeaderMap) -> axum::response::Response {
let path = uri.path();
// Try serving the exact asset (with encoding negotiation)
if let Some(response) = try_serve_asset_with_encoding(path, &request_headers) {
return response;
} else {
// Any assets that are not found should be treated as a 404, not falling back to the SPA index.html
if path.starts_with("assets/") {
return (StatusCode::NOT_FOUND, "Asset not found").into_response();
}
}
// Fall back to the SPA index.html
match WebAssets::get("index.html") {
Some(content) => {
let metadata = get_asset_metadata_cached("index.html", &content.data);
// SvelteKit assets under _app/ that don't exist are a hard 404
let trimmed = path.trim_start_matches('/');
if trimmed.starts_with("_app/") || trimmed.starts_with("assets/") {
return (StatusCode::NOT_FOUND, "Asset not found").into_response();
}
// Check if client has a matching ETag for index.html
if let Some(etag) = request_headers.get(header::IF_NONE_MATCH)
&& etag.to_str().is_ok_and(|s| metadata.etag_matches(s))
{
return StatusCode::NOT_MODIFIED.into_response();
}
let mut response = Html(content.data).into_response();
set_caching_headers(&mut response, "index.html", &metadata.hash.quoted());
response
}
// SPA fallback: serve index.html with encoding negotiation
match try_serve_asset_with_encoding("/index.html", &request_headers) {
Some(response) => response,
None => (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load index.html",
@@ -284,20 +246,130 @@ async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
}
/// Metrics endpoint for monitoring
async fn metrics() -> Json<Value> {
// For now, return basic metrics structure
Json(json!({
"banner_api": {
"status": "connected"
},
"timestamp": chrono::Utc::now().to_rfc3339()
}))
async fn metrics(
State(state): State<AppState>,
Query(params): Query<MetricsParams>,
) -> Result<Json<Value>, (AxumStatusCode, String)> {
let limit = params.limit.clamp(1, 5000);
// Parse range shorthand, defaulting to 24h
let range_str = params.range.as_deref().unwrap_or("24h");
let duration = match range_str {
"1h" => chrono::Duration::hours(1),
"6h" => chrono::Duration::hours(6),
"24h" => chrono::Duration::hours(24),
"7d" => chrono::Duration::days(7),
"30d" => chrono::Duration::days(30),
_ => {
return Err((
AxumStatusCode::BAD_REQUEST,
format!("Invalid range '{range_str}'. Valid: 1h, 6h, 24h, 7d, 30d"),
));
}
};
let since = chrono::Utc::now() - duration;
// Resolve course_id: explicit param takes priority, then term+crn lookup
let course_id = if let Some(id) = params.course_id {
Some(id)
} else if let (Some(term), Some(crn)) = (params.term.as_deref(), params.crn.as_deref()) {
let row: Option<(i32,)> =
sqlx::query_as("SELECT id FROM courses WHERE term_code = $1 AND crn = $2")
.bind(term)
.bind(crn)
.fetch_optional(&state.db_pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Course lookup for metrics failed");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Course lookup failed".to_string(),
)
})?;
row.map(|(id,)| id)
} else {
None
};
// Build query dynamically based on filters
let metrics: Vec<(i32, i32, chrono::DateTime<chrono::Utc>, i32, i32, i32)> =
if let Some(cid) = course_id {
sqlx::query_as(
"SELECT id, course_id, timestamp, enrollment, wait_count, seats_available \
FROM course_metrics \
WHERE course_id = $1 AND timestamp >= $2 \
ORDER BY timestamp DESC \
LIMIT $3",
)
.bind(cid)
.bind(since)
.bind(limit)
.fetch_all(&state.db_pool)
.await
} else {
sqlx::query_as(
"SELECT id, course_id, timestamp, enrollment, wait_count, seats_available \
FROM course_metrics \
WHERE timestamp >= $1 \
ORDER BY timestamp DESC \
LIMIT $2",
)
.bind(since)
.bind(limit)
.fetch_all(&state.db_pool)
.await
}
.map_err(|e| {
tracing::error!(error = %e, "Metrics query failed");
(
AxumStatusCode::INTERNAL_SERVER_ERROR,
"Metrics query failed".to_string(),
)
})?;
let count = metrics.len();
let metrics_json: Vec<Value> = metrics
.into_iter()
.map(
|(id, course_id, timestamp, enrollment, wait_count, seats_available)| {
json!({
"id": id,
"courseId": course_id,
"timestamp": timestamp.to_rfc3339(),
"enrollment": enrollment,
"waitCount": wait_count,
"seatsAvailable": seats_available,
})
},
)
.collect();
Ok(Json(json!({
"metrics": metrics_json,
"count": count,
"timestamp": chrono::Utc::now().to_rfc3339(),
})))
}
// ============================================================
// Course search & detail API
// ============================================================
#[derive(Deserialize)]
struct MetricsParams {
course_id: Option<i32>,
term: Option<String>,
crn: Option<String>,
/// Shorthand durations: "1h", "6h", "24h", "7d", "30d"
range: Option<String>,
#[serde(default = "default_metrics_limit")]
limit: i32,
}
fn default_metrics_limit() -> i32 {
500
}
#[derive(Deserialize)]
struct SubjectsParams {
term: String,
@@ -369,6 +441,7 @@ pub struct InstructorResponse {
is_primary: bool,
rmp_rating: Option<f32>,
rmp_num_ratings: Option<i32>,
rmp_legacy_id: Option<i32>,
}
#[derive(Serialize, TS)]
@@ -403,6 +476,7 @@ fn build_course_response(
is_primary: i.is_primary,
rmp_rating: i.avg_rating,
rmp_num_ratings: i.num_ratings,
rmp_legacy_id: i.rmp_legacy_id,
})
.collect();
+188
View File
@@ -0,0 +1,188 @@
//! In-memory caches for session resolution and OAuth CSRF state.
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use rand::Rng;
use sqlx::PgPool;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::data::models::User;
/// Cached session entry with TTL.
#[derive(Debug, Clone)]
struct CachedSession {
user: User,
session_expires_at: DateTime<Utc>,
cached_at: Instant,
}
/// In-memory session cache backed by PostgreSQL.
///
/// Provides fast session resolution without a DB round-trip on every request.
/// Cache entries expire after a configurable TTL (default 5 minutes).
#[derive(Clone)]
pub struct SessionCache {
cache: Arc<DashMap<String, CachedSession>>,
db_pool: PgPool,
cache_ttl: Duration,
}
impl SessionCache {
/// Create a new session cache with a 5-minute default TTL.
pub fn new(db_pool: PgPool) -> Self {
Self {
cache: Arc::new(DashMap::new()),
db_pool,
cache_ttl: Duration::from_secs(5 * 60),
}
}
/// Resolve a session token to a [`User`], using the cache when possible.
///
/// On cache hit (entry present, not stale, session not expired), returns the
/// cached user immediately. On miss or stale entry, queries the database for
/// the session and user, populates the cache, and fire-and-forgets a
/// `touch_session` call to update `last_active_at`.
pub async fn get_user(&self, token: &str) -> Option<User> {
// Check cache first
if let Some(entry) = self.cache.get(token) {
let now_instant = Instant::now();
let now_utc = Utc::now();
let cache_fresh = entry.cached_at + self.cache_ttl > now_instant;
let session_valid = entry.session_expires_at > now_utc;
if cache_fresh && session_valid {
return Some(entry.user.clone());
}
// Stale or expired — drop the ref before removing
drop(entry);
self.cache.remove(token);
}
// Cache miss — query DB
let session = crate::data::sessions::get_session(&self.db_pool, token)
.await
.ok()
.flatten()?;
let user = crate::data::users::get_user(&self.db_pool, session.user_id)
.await
.ok()
.flatten()?;
self.cache.insert(
token.to_owned(),
CachedSession {
user: user.clone(),
session_expires_at: session.expires_at,
cached_at: Instant::now(),
},
);
// Fire-and-forget touch to update last_active_at
let pool = self.db_pool.clone();
let token_owned = token.to_owned();
tokio::spawn(async move {
if let Err(e) = crate::data::sessions::touch_session(&pool, &token_owned).await {
tracing::warn!(error = %e, "failed to touch session");
}
});
Some(user)
}
/// Remove a single session from the cache (e.g. on logout).
pub fn evict(&self, token: &str) {
self.cache.remove(token);
}
/// Remove all cached sessions belonging to a user.
pub fn evict_user(&self, discord_id: i64) {
self.cache
.retain(|_, entry| entry.user.discord_id != discord_id);
}
/// Delete expired sessions from the database and sweep the in-memory cache.
///
/// Returns the number of sessions deleted from the database.
#[allow(dead_code)] // Intended for periodic cleanup task (not yet wired)
pub async fn cleanup_expired(&self) -> anyhow::Result<u64> {
let deleted = crate::data::sessions::cleanup_expired(&self.db_pool).await?;
let now = Utc::now();
self.cache.retain(|_, entry| entry.session_expires_at > now);
Ok(deleted)
}
}
/// Data stored alongside each OAuth CSRF state token.
struct OAuthStateEntry {
created_at: Instant,
/// The browser origin that initiated the login flow, so the callback
/// can reconstruct the exact redirect_uri Discord expects.
origin: String,
}
/// Ephemeral store for OAuth CSRF state tokens.
///
/// Tokens are stored with creation time and expire after a configurable TTL.
/// Each token is single-use: validation consumes it.
#[derive(Clone)]
pub struct OAuthStateStore {
states: Arc<DashMap<String, OAuthStateEntry>>,
ttl: Duration,
}
impl Default for OAuthStateStore {
fn default() -> Self {
Self::new()
}
}
impl OAuthStateStore {
/// Create a new store with a 10-minute TTL.
pub fn new() -> Self {
Self {
states: Arc::new(DashMap::new()),
ttl: Duration::from_secs(10 * 60),
}
}
/// Generate a random 16-byte hex CSRF token, store it with the given
/// origin, and return the token.
pub fn generate(&self, origin: String) -> String {
let bytes: [u8; 16] = rand::rng().random();
let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
self.states.insert(
token.clone(),
OAuthStateEntry {
created_at: Instant::now(),
origin,
},
);
token
}
/// Validate and consume a CSRF token. Returns the stored origin if the
/// token was present and not expired.
pub fn validate(&self, state: &str) -> Option<String> {
let (_, entry) = self.states.remove(state)?;
if entry.created_at.elapsed() < self.ttl {
Some(entry.origin)
} else {
None
}
}
/// Remove all expired entries from the store.
#[allow(dead_code)] // Intended for periodic cleanup task (not yet wired)
pub fn cleanup(&self) {
let ttl = self.ttl;
self.states
.retain(|_, entry| entry.created_at.elapsed() < ttl);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! WebSocket event types and handler for real-time scrape job updates.
use axum::{
extract::{
State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
};
use futures::{SinkExt, StreamExt};
use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tracing::debug;
use crate::data::models::{ScrapeJob, ScrapeJobStatus};
use crate::state::AppState;
use crate::web::extractors::AdminUser;
/// A serializable DTO for `ScrapeJob` with computed `status`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrapeJobDto {
pub id: i32,
pub target_type: String,
pub target_payload: serde_json::Value,
pub priority: String,
pub execute_at: String,
pub created_at: String,
pub locked_at: Option<String>,
pub retry_count: i32,
pub max_retries: i32,
pub queued_at: String,
pub status: ScrapeJobStatus,
}
impl From<&ScrapeJob> for ScrapeJobDto {
fn from(job: &ScrapeJob) -> Self {
Self {
id: job.id,
target_type: format!("{:?}", job.target_type),
target_payload: job.target_payload.clone(),
priority: format!("{:?}", job.priority),
execute_at: job.execute_at.to_rfc3339(),
created_at: job.created_at.to_rfc3339(),
locked_at: job.locked_at.map(|t| t.to_rfc3339()),
retry_count: job.retry_count,
max_retries: job.max_retries,
queued_at: job.queued_at.to_rfc3339(),
status: job.status(),
}
}
}
/// Events broadcast when scrape job state changes.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ScrapeJobEvent {
Init {
jobs: Vec<ScrapeJobDto>,
},
JobCreated {
job: ScrapeJobDto,
},
JobLocked {
id: i32,
locked_at: String,
status: ScrapeJobStatus,
},
JobCompleted {
id: i32,
},
JobRetried {
id: i32,
retry_count: i32,
queued_at: String,
status: ScrapeJobStatus,
},
JobExhausted {
id: i32,
},
JobDeleted {
id: i32,
},
}
/// Fetch current scrape jobs from the DB and build an `Init` event.
async fn build_init_event(db_pool: &PgPool) -> Result<ScrapeJobEvent, sqlx::Error> {
let rows = sqlx::query_as::<_, ScrapeJob>(
"SELECT * FROM scrape_jobs ORDER BY priority DESC, execute_at ASC LIMIT 100",
)
.fetch_all(db_pool)
.await?;
let jobs = rows.iter().map(ScrapeJobDto::from).collect();
Ok(ScrapeJobEvent::Init { jobs })
}
/// WebSocket endpoint for real-time scrape job updates.
///
/// Auth is checked via `AdminUser` before the upgrade occurs — if rejected,
/// a 401/403 is returned and the upgrade never happens.
pub async fn scrape_jobs_ws(
ws: WebSocketUpgrade,
AdminUser(_user): AdminUser,
State(state): State<AppState>,
) -> impl IntoResponse {
ws.on_upgrade(|socket| handle_scrape_jobs_ws(socket, state))
}
/// Serialize an event and send it over the WebSocket sink.
/// Returns `true` if the message was sent, `false` if the client disconnected.
async fn send_event(
sink: &mut futures::stream::SplitSink<WebSocket, Message>,
event: &ScrapeJobEvent,
) -> bool {
let Ok(json) = serde_json::to_string(event) else {
return true; // serialization failed, but connection is still alive
};
sink.send(Message::Text(json.into())).await.is_ok()
}
async fn handle_scrape_jobs_ws(socket: WebSocket, state: AppState) {
debug!("scrape-jobs WebSocket connected");
let (mut sink, mut stream) = socket.split();
// Send initial state
let init_event = match build_init_event(&state.db_pool).await {
Ok(event) => event,
Err(e) => {
debug!(error = %e, "failed to build init event, closing WebSocket");
return;
}
};
if !send_event(&mut sink, &init_event).await {
debug!("client disconnected during init send");
return;
}
// Subscribe to broadcast events
let mut rx = state.scrape_job_events();
loop {
tokio::select! {
result = rx.recv() => {
match result {
Ok(ref event) => {
if !send_event(&mut sink, event).await {
debug!("client disconnected during event send");
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
debug!(missed = n, "broadcast lagged, resyncing");
match build_init_event(&state.db_pool).await {
Ok(ref event) => {
if !send_event(&mut sink, event).await {
debug!("client disconnected during resync send");
break;
}
}
Err(e) => {
debug!(error = %e, "failed to build resync init event");
}
}
}
Err(broadcast::error::RecvError::Closed) => {
debug!("broadcast channel closed");
break;
}
}
}
msg = stream.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text)
&& parsed.get("type").and_then(|t| t.as_str()) == Some("resync")
{
debug!("client requested resync");
match build_init_event(&state.db_pool).await {
Ok(ref event) => {
if !send_event(&mut sink, event).await {
debug!("client disconnected during resync send");
break;
}
}
Err(e) => {
debug!(error = %e, "failed to build resync init event");
}
}
}
}
Some(Ok(Message::Close(_))) | None => {
debug!("client disconnected");
break;
}
_ => {}
}
}
}
}
debug!("scrape-jobs WebSocket disconnected");
}
+113
View File
@@ -210,3 +210,116 @@ async fn test_batch_upsert_unique_constraint_crn_term(pool: PgPool) {
assert_eq!(rows[1].0, "202520");
assert_eq!(rows[1].1, 10);
}
#[sqlx::test]
async fn test_batch_upsert_creates_audit_and_metric_entries(pool: PgPool) {
// Insert initial data — should NOT create audits/metrics (it's a fresh insert)
let initial = vec![helpers::make_course(
"50001",
"202510",
"CS",
"3443",
"App Programming",
10,
35,
0,
5,
)];
batch_upsert_courses(&initial, &pool).await.unwrap();
let (audit_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_audits")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
audit_count, 0,
"initial insert should not create audit entries"
);
let (metric_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
metric_count, 0,
"initial insert should not create metric entries"
);
// Update enrollment and wait_count
let updated = vec![helpers::make_course(
"50001",
"202510",
"CS",
"3443",
"App Programming",
20,
35,
2,
5,
)];
batch_upsert_courses(&updated, &pool).await.unwrap();
// Should have audit entries for enrollment and wait_count changes
let (audit_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_audits")
.fetch_one(&pool)
.await
.unwrap();
assert!(
audit_count >= 2,
"should have audit entries for enrollment and wait_count changes, got {audit_count}"
);
// Should have exactly 1 metric entry
let (metric_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(metric_count, 1, "should have 1 metric snapshot");
// Verify metric values
let (enrollment, wait_count, seats): (i32, i32, i32) = sqlx::query_as(
"SELECT enrollment, wait_count, seats_available FROM course_metrics LIMIT 1",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(enrollment, 20);
assert_eq!(wait_count, 2);
assert_eq!(seats, 15); // 35 - 20
}
#[sqlx::test]
async fn test_batch_upsert_no_change_no_audit(pool: PgPool) {
// Insert then re-insert identical data — should produce zero audits/metrics
let course = vec![helpers::make_course(
"60001",
"202510",
"CS",
"1083",
"Intro to CS",
25,
30,
0,
5,
)];
batch_upsert_courses(&course, &pool).await.unwrap();
batch_upsert_courses(&course, &pool).await.unwrap();
let (audit_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_audits")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
audit_count, 0,
"identical re-upsert should not create audit entries"
);
let (metric_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM course_metrics")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
metric_count, 0,
"identical re-upsert should not create metric entries"
);
}
+17 -11
View File
@@ -217,10 +217,13 @@ async fn unlock_and_increment_retry_has_retries_remaining(pool: PgPool) {
)
.await;
let has_retries = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
let result = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
.await
.unwrap();
assert!(has_retries, "should have retries remaining (0→1, max=3)");
assert!(
result.is_some(),
"should have retries remaining (0→1, max=3)"
);
// Verify state in DB
let (retry_count, locked_at): (i32, Option<chrono::DateTime<chrono::Utc>>) =
@@ -241,17 +244,17 @@ async fn unlock_and_increment_retry_exhausted(pool: PgPool) {
json!({"subject": "CS"}),
ScrapePriority::Medium,
true,
2, // retry_count
3, // retry_count (already used all 3 retries)
3, // max_retries
)
.await;
let has_retries = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
let result = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
.await
.unwrap();
assert!(
!has_retries,
"should NOT have retries remaining (2→3, max=3)"
result.is_none(),
"should NOT have retries remaining (3→4, max=3)"
);
let (retry_count,): (i32,) =
@@ -260,7 +263,7 @@ async fn unlock_and_increment_retry_exhausted(pool: PgPool) {
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(retry_count, 3);
assert_eq!(retry_count, 4);
}
#[sqlx::test]
@@ -276,11 +279,11 @@ async fn unlock_and_increment_retry_already_exceeded(pool: PgPool) {
)
.await;
let has_retries = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
let result = scrape_jobs::unlock_and_increment_retry(id, 3, &pool)
.await
.unwrap();
assert!(
!has_retries,
result.is_none(),
"should NOT have retries remaining (5→6, max=3)"
);
@@ -346,7 +349,7 @@ async fn find_existing_payloads_returns_matching(pool: PgPool) {
}
#[sqlx::test]
async fn find_existing_payloads_ignores_locked(pool: PgPool) {
async fn find_existing_payloads_includes_locked(pool: PgPool) {
let payload = json!({"subject": "CS"});
helpers::insert_scrape_job(
@@ -365,7 +368,10 @@ async fn find_existing_payloads_ignores_locked(pool: PgPool) {
.await
.unwrap();
assert!(existing.is_empty(), "locked jobs should be ignored");
assert!(
existing.contains(&payload.to_string()),
"locked jobs should be included in deduplication"
);
}
#[sqlx::test]
+3
View File
@@ -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=="],
+1
View File
@@ -33,6 +33,7 @@
"vitest": "^3.0.5"
},
"dependencies": {
"date-fns": "^4.1.0",
"overlayscrollbars": "^2.14.0",
"overlayscrollbars-svelte": "^0.5.5"
}
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bun
/**
* Pre-compress static assets with maximum compression levels.
* Run after `bun run build`.
*
* Generates .gz, .br, .zst variants for compressible files ≥ MIN_SIZE bytes.
* These are embedded alongside originals by rust-embed and served via
* content negotiation in src/web/assets.rs.
*/
import { readdir, stat, readFile, writeFile } from "fs/promises";
import { join, extname } from "path";
import { gzipSync, brotliCompressSync, constants } from "zlib";
import { $ } from "bun";
// Must match COMPRESSION_MIN_SIZE in src/web/encoding.rs
const MIN_SIZE = 512;
const COMPRESSIBLE_EXTENSIONS = new Set([
".js",
".css",
".html",
".json",
".svg",
".txt",
".xml",
".map",
]);
// Check if zstd CLI is available
let hasZstd = false;
try {
await $`which zstd`.quiet();
hasZstd = true;
} catch {
console.warn("Warning: zstd not found, skipping .zst generation");
}
async function* walkDir(dir: string): AsyncGenerator<string> {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
yield* walkDir(path);
} else if (entry.isFile()) {
yield path;
}
}
} catch {
// Directory doesn't exist, skip
}
}
async function compressFile(path: string): Promise<void> {
const ext = extname(path);
if (!COMPRESSIBLE_EXTENSIONS.has(ext)) return;
if (path.endsWith(".br") || path.endsWith(".gz") || path.endsWith(".zst")) return;
const stats = await stat(path);
if (stats.size < MIN_SIZE) return;
// Skip if all compressed variants already exist
const variantsExist = await Promise.all([
stat(`${path}.br`).then(
() => true,
() => false
),
stat(`${path}.gz`).then(
() => true,
() => false
),
hasZstd
? stat(`${path}.zst`).then(
() => true,
() => false
)
: Promise.resolve(false),
]);
if (variantsExist.every((exists) => exists || !hasZstd)) {
return;
}
const content = await readFile(path);
const originalSize = content.length;
// Brotli (maximum quality = 11)
const brContent = brotliCompressSync(content, {
params: {
[constants.BROTLI_PARAM_QUALITY]: 11,
},
});
await writeFile(`${path}.br`, brContent);
// Gzip (level 9)
const gzContent = gzipSync(content, { level: 9 });
await writeFile(`${path}.gz`, gzContent);
// Zstd (level 19 - maximum)
if (hasZstd) {
try {
await $`zstd -19 -q -f -o ${path}.zst ${path}`.quiet();
} catch (e) {
console.warn(`Warning: Failed to compress ${path} with zstd: ${e}`);
}
}
const brRatio = ((brContent.length / originalSize) * 100).toFixed(1);
const gzRatio = ((gzContent.length / originalSize) * 100).toFixed(1);
console.log(`Compressed: ${path} (br: ${brRatio}%, gz: ${gzRatio}%, ${originalSize} bytes)`);
}
async function main() {
console.log("Pre-compressing static assets...");
// Banner uses adapter-static with output in dist/
const dirs = ["dist"];
let scannedFiles = 0;
let compressedFiles = 0;
for (const dir of dirs) {
for await (const file of walkDir(dir)) {
const ext = extname(file);
scannedFiles++;
if (
COMPRESSIBLE_EXTENSIONS.has(ext) &&
!file.endsWith(".br") &&
!file.endsWith(".gz") &&
!file.endsWith(".zst")
) {
const stats = await stat(file);
if (stats.size >= MIN_SIZE) {
await compressFile(file);
compressedFiles++;
}
}
}
}
console.log(`Done! Scanned ${scannedFiles} files, compressed ${compressedFiles} files.`);
}
main().catch((e) => {
console.error("Compression failed:", e);
process.exit(1);
});
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" class="no-transition">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+134
View File
@@ -7,6 +7,7 @@ import type {
ServiceInfo,
ServiceStatus,
StatusResponse,
User,
} from "$lib/bindings";
const API_BASE_URL = "/api";
@@ -34,6 +35,72 @@ export type SearchResponse = SearchResponseGenerated;
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
export type SortDirection = "asc" | "desc";
export interface AdminStatus {
userCount: number;
sessionCount: number;
courseCount: number;
scrapeJobCount: number;
services: { name: string; status: string }[];
}
export interface ScrapeJob {
id: number;
targetType: string;
targetPayload: unknown;
priority: string;
executeAt: string;
createdAt: string;
lockedAt: string | null;
retryCount: number;
maxRetries: number;
queuedAt: string;
status: "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
}
export interface ScrapeJobsResponse {
jobs: ScrapeJob[];
}
export interface AuditLogEntry {
id: number;
courseId: number;
timestamp: string;
fieldChanged: string;
oldValue: string;
newValue: string;
subject: string | null;
courseNumber: string | null;
crn: string | null;
courseTitle: string | null;
}
export interface AuditLogResponse {
entries: AuditLogEntry[];
}
export interface MetricEntry {
id: number;
courseId: number;
timestamp: string;
enrollment: number;
waitCount: number;
seatsAvailable: number;
}
export interface MetricsResponse {
metrics: MetricEntry[];
count: number;
timestamp: string;
}
export interface MetricsParams {
course_id?: number;
term?: string;
crn?: string;
range?: "1h" | "6h" | "24h" | "7d" | "30d";
limit?: number;
}
export interface SearchParams {
term: string;
subjects?: string[];
@@ -96,6 +163,73 @@ export class BannerApiClient {
async getReference(category: string): Promise<ReferenceEntry[]> {
return this.request<ReferenceEntry[]>(`/reference/${encodeURIComponent(category)}`);
}
// Admin endpoints
async getAdminStatus(): Promise<AdminStatus> {
return this.request<AdminStatus>("/admin/status");
}
async getAdminUsers(): Promise<User[]> {
return this.request<User[]>("/admin/users");
}
async setUserAdmin(discordId: string, isAdmin: boolean): Promise<User> {
const response = await this.fetchFn(`${this.baseUrl}/admin/users/${discordId}/admin`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_admin: isAdmin }),
});
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
return (await response.json()) as User;
}
async getAdminScrapeJobs(): Promise<ScrapeJobsResponse> {
return this.request<ScrapeJobsResponse>("/admin/scrape-jobs");
}
/**
* 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));
if (params?.term) query.set("term", params.term);
if (params?.crn) query.set("crn", params.crn);
if (params?.range) query.set("range", params.range);
if (params?.limit !== undefined) query.set("limit", String(params.limit));
const qs = query.toString();
return this.request<MetricsResponse>(`/metrics${qs ? `?${qs}` : ""}`);
}
}
export const client = new BannerApiClient();
+77
View File
@@ -0,0 +1,77 @@
import type { User } from "$lib/bindings";
type AuthState =
| { mode: "loading" }
| { mode: "authenticated"; user: User }
| { mode: "unauthenticated" };
class AuthStore {
state = $state<AuthState>({ mode: "loading" });
get user(): User | null {
return this.state.mode === "authenticated" ? this.state.user : null;
}
get isAdmin(): boolean {
return this.user?.isAdmin ?? false;
}
get isLoading(): boolean {
return this.state.mode === "loading";
}
get isAuthenticated(): boolean {
return this.state.mode === "authenticated";
}
/**
* Attempt to load the current user session from the backend.
* Only transitions to "unauthenticated" on a definitive 401/403.
* Retries indefinitely on transient failures (network errors, 5xx)
* so that a slow backend startup doesn't kick the user to login.
*/
async init() {
const MAX_DELAY_MS = 7_000;
let delayMs = 500;
for (;;) {
try {
const response = await fetch("/api/auth/me");
if (response.ok) {
const user: User = await response.json();
this.state = { mode: "authenticated", user };
return;
}
// Definitive rejection — no session or not authorized
if (response.status === 401 || response.status === 403) {
this.state = { mode: "unauthenticated" };
return;
}
// Server error (5xx) or unexpected status — retry
} catch {
// Network error (backend not up yet) — retry
}
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
}
}
login() {
window.location.href = "/api/auth/login";
}
async logout() {
try {
await fetch("/api/auth/logout", { method: "POST" });
} finally {
this.state = { mode: "unauthenticated" };
window.location.href = "/";
}
}
}
export const authStore = new AuthStore();
+1
View File
@@ -6,3 +6,4 @@ export type { SearchResponse } from "./SearchResponse";
export type { ServiceInfo } from "./ServiceInfo";
export type { ServiceStatus } from "./ServiceStatus";
export type { StatusResponse } from "./StatusResponse";
export type { User } from "./User";
+282 -202
View File
@@ -7,13 +7,16 @@ import {
formatMeetingDaysLong,
isMeetingTimeTBA,
isTimeTBA,
ratingColor,
ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { cn, tooltipContentClass } from "$lib/utils";
import { cn, tooltipContentClass, formatNumber } from "$lib/utils";
import { Tooltip } from "bits-ui";
import SimpleTooltip from "./SimpleTooltip.svelte";
import { Info, Copy, Check } from "@lucide/svelte";
import { Info, Copy, Check, Star, Triangle, ExternalLink } from "@lucide/svelte";
let { course }: { course: CourseResponse } = $props();
@@ -21,206 +24,283 @@ const clipboard = useClipboard();
</script>
<div class="bg-muted/60 p-5 text-sm border-b border-border">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<!-- Instructors -->
<div>
<h4 class="text-sm text-foreground mb-2">
Instructors
</h4>
{#if course.instructors.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each course.instructors as instructor}
<Tooltip.Root delayDuration={200}>
<Tooltip.Trigger>
<span
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
>
{instructor.displayName}
{#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating}
<span
class="text-[10px] font-semibold {ratingColor(rating)}"
>{rating.toFixed(1)}</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={cn(tooltipContentClass, "px-3 py-2")}
>
<div class="space-y-1.5">
<div class="font-medium">{instructor.displayName}</div>
{#if instructor.isPrimary}
<div class="text-muted-foreground">Primary instructor</div>
{/if}
{#if instructor.rmpRating != null}
<div class="text-muted-foreground">
{instructor.rmpRating.toFixed(1)}/5 ({instructor.rmpNumRatings ?? 0} ratings)
</div>
{/if}
{#if instructor.email}
<button
onclick={(e) => clipboard.copy(instructor.email!, e)}
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{#if clipboard.copiedValue === instructor.email}
<Check class="size-3" />
<span>Copied!</span>
{:else}
<Copy class="size-3" />
<span>{instructor.email}</span>
{/if}
</button>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<!-- Instructors -->
<div>
<h4 class="text-sm text-foreground mb-2">Instructors</h4>
{#if course.instructors.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each course.instructors as instructor}
<Tooltip.Root delayDuration={200}>
<Tooltip.Trigger>
<span
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
>
{instructor.displayName}
{#if instructor.rmpRating != null}
{@const rating = instructor.rmpRating}
{@const lowConfidence =
(instructor.rmpNumRatings ?? 0) <
RMP_CONFIDENCE_THRESHOLD}
<span
class="text-[10px] font-semibold inline-flex items-center gap-0.5"
style={ratingStyle(
rating,
themeStore.isDark,
)}
>
{rating.toFixed(1)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={cn(tooltipContentClass, "px-3 py-2")}
>
<div class="space-y-1.5">
<div class="font-medium">
{instructor.displayName}
</div>
{#if instructor.isPrimary}
<div class="text-muted-foreground">
Primary instructor
</div>
{/if}
{#if instructor.rmpRating != null}
<div class="text-muted-foreground">
{instructor.rmpRating.toFixed(1)}/5
· {instructor.rmpNumRatings ?? 0} ratings
{#if (instructor.rmpNumRatings ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
</div>
{/if}
{#if instructor.rmpLegacyId != null}
<a
href={rmpUrl(
instructor.rmpLegacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink class="size-3" />
<span>View on RMP</span>
</a>
{/if}
{#if instructor.email}
<button
onclick={(e) =>
clipboard.copy(
instructor.email!,
e,
)}
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
{#if clipboard.copiedValue === instructor.email}
<Check class="size-3" />
<span>Copied!</span>
{:else}
<Copy class="size-3" />
<span>{instructor.email}</span>
{/if}
</button>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{:else}
<span class="text-muted-foreground italic">Staff</span>
{/if}
</div>
<!-- Meeting Times -->
<div>
<h4 class="text-sm text-foreground mb-2">
Meeting Times
</h4>
{#if course.meetingTimes.length > 0}
<ul class="space-y-2">
{#each course.meetingTimes as mt}
<li>
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
<span class="italic text-muted-foreground">TBA</span>
{:else}
<div class="flex items-baseline gap-1.5">
{#if !isMeetingTimeTBA(mt)}
<span class="font-medium text-foreground">
{formatMeetingDaysLong(mt)}
</span>
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">
{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)}
</span>
{:else}
<span class="italic text-muted-foreground">Time TBA</span>
{/if}
</div>
{/if}
{#if mt.building || mt.room}
<div class="text-xs text-muted-foreground mt-0.5">
{mt.building_description ?? mt.building}{mt.room ? ` ${mt.room}` : ""}
</div>
{/if}
<div class="text-xs text-muted-foreground/70 mt-0.5">
{formatDate(mt.start_date)} &ndash; {formatDate(mt.end_date)}
</div>
</li>
{/each}
</ul>
{:else}
<span class="italic text-muted-foreground">TBA</span>
{/if}
</div>
<!-- Delivery -->
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Delivery
<SimpleTooltip text="How the course is taught: in-person, online, hybrid, etc." delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<span class="text-foreground">
{course.instructionalMethod ?? "—"}
{#if course.campus}
<span class="text-muted-foreground"> · {course.campus}</span>
{/if}
</span>
</div>
<!-- Credits -->
<div>
<h4 class="text-sm text-foreground mb-2">
Credits
</h4>
<span class="text-foreground">{formatCreditHours(course)}</span>
</div>
<!-- Attributes -->
{#if course.attributes.length > 0}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Attributes
<SimpleTooltip text="Course flags for degree requirements, core curriculum, or special designations" delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<div class="flex flex-wrap gap-1.5">
{#each course.attributes as attr}
<SimpleTooltip text="Course attribute code" delay={150} passthrough>
<span
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
{attr}
</span>
</SimpleTooltip>
{/each}
</div>
</div>
{/if}
<!-- Cross-list -->
{#if course.crossList}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Cross-list
<SimpleTooltip text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class." delay={150} passthrough>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1.5 text-foreground font-mono">
<span class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium">
{course.crossList}
</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
<span class="text-muted-foreground text-xs">
{course.crossListCount}/{course.crossListCapacity}
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={tooltipContentClass}
>
Group <span class="font-mono font-medium">{course.crossList}</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
{course.crossListCount} enrolled across {course.crossListCapacity} shared seats
{:else}
<span class="text-muted-foreground italic">Staff</span>
{/if}
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
</div>
<!-- Waitlist -->
{#if course.waitCapacity > 0}
<div>
<h4 class="text-sm text-foreground mb-2">
Waitlist
</h4>
<span class="text-foreground">{course.waitCount} / {course.waitCapacity}</span>
</div>
{/if}
</div>
<!-- Meeting Times -->
<div>
<h4 class="text-sm text-foreground mb-2">Meeting Times</h4>
{#if course.meetingTimes.length > 0}
<ul class="space-y-2">
{#each course.meetingTimes as mt}
<li>
{#if isMeetingTimeTBA(mt) && isTimeTBA(mt)}
<span class="italic text-muted-foreground"
>TBA</span
>
{:else}
<div class="flex items-baseline gap-1.5">
{#if !isMeetingTimeTBA(mt)}
<span
class="font-medium text-foreground"
>
{formatMeetingDaysLong(mt)}
</span>
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">
{formatTime(
mt.begin_time,
)}&ndash;{formatTime(mt.end_time)}
</span>
{:else}
<span
class="italic text-muted-foreground"
>Time TBA</span
>
{/if}
</div>
{/if}
{#if mt.building || mt.room}
<div
class="text-xs text-muted-foreground mt-0.5"
>
{mt.building_description ??
mt.building}{mt.room
? ` ${mt.room}`
: ""}
</div>
{/if}
<div
class="text-xs text-muted-foreground/70 mt-0.5"
>
{formatDate(mt.start_date)} &ndash; {formatDate(
mt.end_date,
)}
</div>
</li>
{/each}
</ul>
{:else}
<span class="italic text-muted-foreground">TBA</span>
{/if}
</div>
<!-- Delivery -->
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Delivery
<SimpleTooltip
text="How the course is taught: in-person, online, hybrid, etc."
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<span class="text-foreground">
{course.instructionalMethod ?? "—"}
{#if course.campus}
<span class="text-muted-foreground">
· {course.campus}
</span>
{/if}
</span>
</div>
<!-- Credits -->
<div>
<h4 class="text-sm text-foreground mb-2">Credits</h4>
<span class="text-foreground">{formatCreditHours(course)}</span>
</div>
<!-- Attributes -->
{#if course.attributes.length > 0}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Attributes
<SimpleTooltip
text="Course flags for degree requirements, core curriculum, or special designations"
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<div class="flex flex-wrap gap-1.5">
{#each course.attributes as attr}
<SimpleTooltip
text="Course attribute code"
delay={150}
passthrough
>
<span
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
>
{attr}
</span>
</SimpleTooltip>
{/each}
</div>
</div>
{/if}
<!-- Cross-list -->
{#if course.crossList}
<div>
<h4 class="text-sm text-foreground mb-2">
<span class="inline-flex items-center gap-1">
Cross-list
<SimpleTooltip
text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class."
delay={150}
passthrough
>
<Info class="size-3 text-muted-foreground/50" />
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger>
<span
class="inline-flex items-center gap-1.5 text-foreground font-mono"
>
<span
class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium"
>
{course.crossList}
</span>
{#if course.crossListCount != null && course.crossListCapacity != null}
<span class="text-muted-foreground text-xs">
{formatNumber(course.crossListCount)}/{formatNumber(course.crossListCapacity)}
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
Group <span class="font-mono font-medium"
>{course.crossList}</span
>
{#if course.crossListCount != null && course.crossListCapacity != null}
{formatNumber(course.crossListCount)} enrolled across {formatNumber(course.crossListCapacity)}
shared seats
{/if}
</Tooltip.Content>
</Tooltip.Root>
</div>
{/if}
<!-- Waitlist -->
{#if course.waitCapacity > 0}
<div>
<h4 class="text-sm text-foreground mb-2">Waitlist</h4>
<span class="text-2foreground"
>{formatNumber(course.waitCount)} / {formatNumber(course.waitCapacity)}</span
>
</div>
{/if}
</div>
</div>
+161 -58
View File
@@ -15,8 +15,11 @@ import {
openSeats,
seatsColor,
seatsDotColor,
ratingColor,
ratingStyle,
rmpUrl,
RMP_CONFIDENCE_THRESHOLD,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { useClipboard } from "$lib/composables/useClipboard.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import CourseDetail from "./CourseDetail.svelte";
@@ -31,8 +34,19 @@ import {
type VisibilityState,
type Updater,
} from "@tanstack/table-core";
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
import { DropdownMenu, ContextMenu } from "bits-ui";
import {
ArrowUp,
ArrowDown,
ArrowUpDown,
Columns3,
Check,
RotateCcw,
Star,
Triangle,
ExternalLink,
} from "@lucide/svelte";
import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui";
import { cn, tooltipContentClass, formatNumber } from "$lib/utils";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
@@ -91,10 +105,16 @@ function primaryInstructorDisplay(course: CourseResponse): string {
return abbreviateInstructor(primary.displayName);
}
function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
function primaryRating(
course: CourseResponse
): { rating: number; count: number; legacyId: number | null } | null {
const primary = getPrimaryInstructor(course.instructors);
if (!primary?.rmpRating) return null;
return { rating: primary.rmpRating, count: primary.rmpNumRatings ?? 0 };
return {
rating: primary.rmpRating,
count: primary.rmpNumRatings ?? 0,
legacyId: primary.rmpLegacyId ?? null,
};
}
function timeIsTBA(course: CourseResponse): boolean {
@@ -207,8 +227,7 @@ const table = createSvelteTable({
</GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label =
typeof col.header === "string" ? col.header : id}
{@const label = typeof col.header === "string" ? col.header : id}
<CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
@@ -269,12 +288,12 @@ const table = createSvelteTable({
transition:fly={{ duration: 150, y: -10 }}
>
{@render columnVisibilityGroup(
DropdownMenu.Group,
DropdownMenu.GroupHeading,
DropdownMenu.CheckboxItem,
DropdownMenu.Separator,
DropdownMenu.Item,
)}
DropdownMenu.Group,
DropdownMenu.GroupHeading,
DropdownMenu.CheckboxItem,
DropdownMenu.Separator,
DropdownMenu.Item,
)}
</div>
</div>
{/if}
@@ -379,12 +398,15 @@ const table = createSvelteTable({
</tr>
</tbody>
{:else}
<!-- No out: transition — Svelte outros break table layout (tbody loses positioning and overlaps) -->
{#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
animate:flip={{ duration: 300 }}
in:fade={{ duration: 200, delay: Math.min(i * 20, 400) }}
out:fade={{ duration: 150 }}
in:fade={{
duration: 200,
delay: Math.min(i * 20, 400),
}}
>
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn ===
@@ -405,9 +427,15 @@ const table = createSvelteTable({
e,
)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
clipboard.copy(course.crn, e);
clipboard.copy(
course.crn,
e,
);
}
}}
aria-label="Copy CRN {course.crn} to clipboard"
@@ -468,9 +496,12 @@ const table = createSvelteTable({
{@const primary = getPrimaryInstructor(
course.instructors,
)}
{@const display = primaryInstructorDisplay(course)}
{@const commaIdx = display.indexOf(", ")}
{@const ratingData = primaryRating(course)}
{@const display =
primaryInstructorDisplay(course)}
{@const commaIdx =
display.indexOf(", ")}
{@const ratingData =
primaryRating(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if display === "Staff"}
<span
@@ -486,38 +517,98 @@ const table = createSvelteTable({
passthrough
>
{#if commaIdx !== -1}
<span>{display.slice(0, commaIdx)},
<span class="text-muted-foreground">{display.slice(commaIdx + 1)}</span
></span>
<span
>{display.slice(
0,
commaIdx,
)},
<span
class="text-muted-foreground"
>{display.slice(
commaIdx +
1,
)}</span
></span
>
{:else}
<span>{display}</span>
{/if}
</SimpleTooltip>
{/if}
{#if ratingData}
<SimpleTooltip
text="{ratingData.rating.toFixed(
1,
)}/5 ({ratingData.count} ratings on RateMyProfessors)"
delay={150}
side="bottom"
passthrough
{@const lowConfidence =
ratingData.count <
RMP_CONFIDENCE_THRESHOLD}
<Tooltip.Root
delayDuration={150}
>
<span
class="ml-1 text-xs font-medium {ratingColor(
ratingData.rating,
)}"
>{ratingData.rating.toFixed(
1,
)}★</span
<Tooltip.Trigger>
<span
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
style={ratingStyle(
ratingData.rating,
themeStore.isDark,
)}
>
{ratingData.rating.toFixed(
1,
)}
{#if lowConfidence}
<Triangle
class="size-2 fill-current"
/>
{:else}
<Star
class="size-2.5 fill-current"
/>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side="bottom"
sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
>
</SimpleTooltip>
<span
class="inline-flex items-center gap-1.5 text-xs"
>
{ratingData.rating.toFixed(
1,
)}/5 · {formatNumber(ratingData.count)}
ratings
{#if (ratingData.count ?? 0) < RMP_CONFIDENCE_THRESHOLD}
(low)
{/if}
{#if ratingData.legacyId != null}
·
<a
href={rmpUrl(
ratingData.legacyId,
)}
target="_blank"
rel="noopener"
class="inline-flex items-center gap-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
RMP
<ExternalLink
class="size-3"
/>
</a>
{/if}
</span>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</td>
{:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap">
<SimpleTooltip
text={formatMeetingTimesTooltip(course.meetingTimes)}
text={formatMeetingTimesTooltip(
course.meetingTimes,
)}
passthrough
>
{#if timeIsTBA(course)}
@@ -566,10 +657,14 @@ const table = createSvelteTable({
</SimpleTooltip>
</td>
{:else if colId === "location"}
{@const concern = getDeliveryConcern(course)}
{@const accentColor = concernAccentColor(concern)}
{@const locTooltip = formatLocationTooltip(course)}
{@const locDisplay = formatLocationDisplay(course)}
{@const concern =
getDeliveryConcern(course)}
{@const accentColor =
concernAccentColor(concern)}
{@const locTooltip =
formatLocationTooltip(course)}
{@const locDisplay =
formatLocationDisplay(course)}
<td class="py-2 px-2 whitespace-nowrap">
{#if locTooltip}
<SimpleTooltip
@@ -579,18 +674,26 @@ const table = createSvelteTable({
>
<span
class="text-muted-foreground"
class:pl-2={accentColor !== null}
style:border-left={accentColor ? `2px solid ${accentColor}` : undefined}
class:pl-2={accentColor !==
null}
style:border-left={accentColor
? `2px solid ${accentColor}`
: undefined}
>
{locDisplay ?? "—"}
</span>
</SimpleTooltip>
{:else if locDisplay}
<span class="text-muted-foreground">
<span
class="text-muted-foreground"
>
{locDisplay}
</span>
{:else}
<span class="text-xs text-muted-foreground/50">—</span>
<span
class="text-xs text-muted-foreground/50"
>—</span
>
{/if}
</td>
{:else if colId === "seats"}
@@ -598,11 +701,11 @@ const table = createSvelteTable({
class="py-2 px-2 text-right whitespace-nowrap"
>
<SimpleTooltip
text="{openSeats(
text="{formatNumber(openSeats(
course,
)} of {course.maxEnrollment} seats open, {course.enrollment} enrolled{course.waitCount >
))} of {formatNumber(course.maxEnrollment)} seats open, {formatNumber(course.enrollment)} enrolled{course.waitCount >
0
? `, ${course.waitCount} waitlisted`
? `, ${formatNumber(course.waitCount)} waitlisted`
: ''}"
delay={200}
side="left"
@@ -626,8 +729,8 @@ const table = createSvelteTable({
>
<span
class="text-muted-foreground/60 tabular-nums"
>{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0}
· WL {course.waitCount}/{course.waitCapacity}{/if}</span
>{formatNumber(course.enrollment)}/{formatNumber(course.maxEnrollment)}{#if course.waitCount > 0}
· WL {formatNumber(course.waitCount)}/{formatNumber(course.waitCapacity)}{/if}</span
>
</span>
</SimpleTooltip>
@@ -668,12 +771,12 @@ const table = createSvelteTable({
out:fade={{ duration: 100 }}
>
{@render columnVisibilityGroup(
ContextMenu.Group,
ContextMenu.GroupHeading,
ContextMenu.CheckboxItem,
ContextMenu.Separator,
ContextMenu.Item,
)}
ContextMenu.Group,
ContextMenu.GroupHeading,
ContextMenu.CheckboxItem,
ContextMenu.Separator,
ContextMenu.Item,
)}
</div>
</div>
{/if}
@@ -0,0 +1,56 @@
<script lang="ts">
import { page } from "$app/state";
import { TriangleAlert, RotateCcw } from "@lucide/svelte";
interface Props {
/** Heading shown in the error card */
title?: string;
/** The error value from svelte:boundary */
error: unknown;
/** Reset callback from svelte:boundary */
reset: () => void;
}
let { title = "Something went wrong", error, reset }: Props = $props();
let errorName = $derived(error instanceof Error ? error.constructor.name : "Error");
let errorMessage = $derived(error instanceof Error ? error.message : String(error));
let errorStack = $derived(error instanceof Error ? error.stack : null);
</script>
<div class="flex items-center justify-center py-16 px-4">
<div class="w-full max-w-lg rounded-lg border border-status-red/25 bg-status-red/5 overflow-hidden text-sm">
<div class="px-4 py-2.5 border-b border-status-red/15 flex items-center justify-between gap-4">
<div class="flex items-center gap-2 text-status-red">
<TriangleAlert size={16} strokeWidth={2.25} />
<span class="font-semibold">{title}</span>
</div>
<span class="text-xs text-muted-foreground font-mono">{page.url.pathname}</span>
</div>
<div class="px-4 py-3 border-b border-status-red/15">
<span class="text-xs text-muted-foreground/70 font-mono">{errorName}</span>
<pre class="mt-1 text-xs text-foreground/80 overflow-auto whitespace-pre-wrap break-words">{errorMessage}</pre>
</div>
{#if errorStack}
<details class="border-b border-status-red/15">
<summary class="px-4 py-2 text-xs text-muted-foreground/70 cursor-pointer hover:text-muted-foreground select-none">
Stack trace
</summary>
<pre class="px-4 py-3 text-xs text-muted-foreground/60 overflow-auto whitespace-pre-wrap break-words max-h-48">{errorStack}</pre>
</details>
{/if}
<div class="px-4 py-2.5 flex items-center justify-end gap-3">
<span class="text-xs text-muted-foreground/60">Retries this section, not the full page</span>
<button
class="shrink-0 cursor-pointer inline-flex items-center gap-1.5 rounded-md bg-status-red px-3 py-1.5 text-sm font-medium text-white hover:brightness-110 transition-all"
onclick={reset}
>
<RotateCcw size={14} strokeWidth={2.25} />
Try again
</button>
</div>
</div>
</div>
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import { page } from "$app/state";
import { Search, User } from "@lucide/svelte";
import { authStore } from "$lib/auth.svelte";
import ThemeToggle from "./ThemeToggle.svelte";
const staticTabs = [{ href: "/", label: "Search", icon: Search }] as const;
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
let profileTab = $derived({
href: authStore.isAuthenticated ? "/profile" : "/login",
label: authStore.isAuthenticated ? "Account" : "Login",
icon: User,
});
function isActive(tabHref: string): boolean {
if (tabHref === "/") return page.url.pathname === "/";
if (tabHref === "/profile") {
return APP_PREFIXES.some((p) => page.url.pathname.startsWith(p));
}
return page.url.pathname.startsWith(tabHref);
}
</script>
<nav class="w-full flex justify-center pt-5 px-5">
<div class="w-full max-w-6xl flex items-center justify-between">
<div class="flex items-center gap-1 rounded-lg bg-muted p-1">
{#each staticTabs as tab}
<a
href={tab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
{isActive(tab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
>
<tab.icon size={15} strokeWidth={2} />
{tab.label}
</a>
{/each}
<a
href={profileTab.href}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors no-underline
{isActive(profileTab.href)
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'}"
>
<User size={15} strokeWidth={2} />
{profileTab.label}
</a>
</div>
<ThemeToggle />
</div>
</nav>
@@ -0,0 +1,76 @@
<script lang="ts">
import { navigationStore } from "$lib/stores/navigation.svelte";
import type { Snippet } from "svelte";
import { cubicOut } from "svelte/easing";
import type { TransitionConfig } from "svelte/transition";
type Axis = "horizontal" | "vertical";
let {
key,
children,
axis = "horizontal",
inDelay = 0,
outDelay = 0,
}: {
key: string;
children: Snippet;
axis?: Axis;
inDelay?: number;
outDelay?: number;
} = $props();
const DURATION = 400;
const OFFSET = 40;
function translate(axis: Axis, value: number): string {
return axis === "vertical" ? `translateY(${value}px)` : `translateX(${value}px)`;
}
function inTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
if (dir === "fade") {
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}`,
};
}
const offset = dir === "right" ? OFFSET : -OFFSET;
return {
duration: DURATION,
delay: inDelay,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
function outTransition(_node: HTMLElement): TransitionConfig {
const dir = navigationStore.direction;
const base = "position: absolute; top: 0; left: 0; width: 100%; height: 100%";
if (dir === "fade") {
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}`,
};
}
const offset = dir === "right" ? -OFFSET : OFFSET;
return {
duration: DURATION,
delay: outDelay,
easing: cubicOut,
css: (t: number) => `${base}; opacity: ${t}; transform: ${translate(axis, (1 - t) * offset)}`,
};
}
</script>
<div class="relative flex flex-1 flex-col overflow-hidden">
{#key key}
<div in:inTransition out:outTransition class="flex flex-1 flex-col">
{@render children()}
</div>
{/key}
</div>
+22 -17
View File
@@ -1,7 +1,20 @@
<script lang="ts">
import { Select } from "bits-ui";
import { ChevronUp, ChevronDown } from "@lucide/svelte";
import { fly } from "svelte/transition";
import type { Action } from "svelte/action";
import { formatNumber } from "$lib/utils";
const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
node.animate(
[
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
{ transform: "translateX(0)", opacity: 1 },
],
{ duration: 200, easing: "ease-out" }
);
}
};
let {
totalCount,
@@ -21,17 +34,8 @@ const start = $derived(offset + 1);
const end = $derived(Math.min(offset + limit, totalCount));
// Track direction for slide animation
let prevPage = $state(1);
let direction = $state(0);
$effect(() => {
const page = currentPage;
if (page !== prevPage) {
direction = page > prevPage ? 1 : -1;
prevPage = page;
}
});
// 5 page slots: current-2, current-1, current, current+1, current+2
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
@@ -40,6 +44,7 @@ function isSlotVisible(page: number): boolean {
}
function goToPage(page: number) {
direction = page > currentPage ? 1 : -1;
onPageChange((page - 1) * limit);
}
@@ -55,11 +60,11 @@ const selectValue = $derived(String(currentPage));
</script>
{#if totalCount > 0 && totalPages > 1}
<div class="flex items-center text-sm">
<div class="flex items-start text-xs -mt-3 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground">
Showing {start}&ndash;{end} of {totalCount} courses
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
@@ -86,7 +91,7 @@ const selectValue = $derived(String(currentPage));
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span in:fly={{ x: direction * 20, duration: 200 }}>{currentPage}</span>
<span use:slideIn={direction}>{currentPage}</span>
<ChevronUp class="size-3 text-muted-foreground" />
</Select.Trigger>
<Select.Portal>
@@ -140,8 +145,8 @@ const selectValue = $derived(String(currentPage));
aria-hidden={!isSlotVisible(page)}
tabindex={isSlotVisible(page) ? 0 : -1}
disabled={!isSlotVisible(page)}
in:fly={{ x: direction * 20, duration: 200 }}
>
use:slideIn={direction}
>
{page}
</button>
{/if}
@@ -154,9 +159,9 @@ const selectValue = $derived(String(currentPage));
</div>
{:else if totalCount > 0}
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-center text-sm">
<div class="flex items-start text-xs -mt-3 pl-2">
<span class="text-muted-foreground">
Showing {start}&ndash;{end} of {totalCount} courses
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
{/if}
@@ -0,0 +1,68 @@
<script lang="ts">
import { onMount } from "svelte";
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import { relativeTime } from "$lib/time";
import { formatNumber } from "$lib/utils";
export interface SearchMeta {
totalCount: number;
durationMs: number;
timestamp: Date;
}
let { meta }: { meta: SearchMeta | null } = $props();
let now = $state(new Date());
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 countLabel = $derived(meta ? formatNumber(meta.totalCount) : "");
let resultNoun = $derived(meta ? (meta.totalCount !== 1 ? "results" : "result") : "");
let durationLabel = $derived(meta ? `${Math.round(meta.durationMs)}ms` : "");
let tooltipText = $derived(meta ? `${relativeTimeText} · ${formattedTime}` : "");
onMount(() => {
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
function scheduleNowTick() {
const delay = relativeTimeResult?.nextUpdateMs ?? 1000;
nowTimeoutId = setTimeout(() => {
now = new Date();
scheduleNowTick();
}, delay);
}
scheduleNowTick();
return () => {
if (nowTimeoutId) clearTimeout(nowTimeoutId);
};
});
</script>
<SimpleTooltip
text={tooltipText}
contentClass="whitespace-nowrap text-[12px] px-2 py-1"
triggerClass="self-start"
sideOffset={0}
>
<span
class="pl-1 text-xs transition-opacity duration-200"
style:opacity={meta ? 1 : 0}
>
<span class="text-muted-foreground/70">{countLabel}</span>
<span class="text-muted-foreground/35">{resultNoun} in</span>
<span class="text-muted-foreground/70">{durationLabel}</span>
</span>
</SimpleTooltip>
+14 -3
View File
@@ -1,30 +1,41 @@
<script lang="ts">
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
import { cn } from "$lib/utils";
let {
text,
delay = 150,
side = "top",
passthrough = false,
triggerClass = "",
contentClass = "",
sideOffset = 6,
children,
}: {
text: string;
delay?: number;
side?: "top" | "bottom" | "left" | "right";
passthrough?: boolean;
triggerClass?: string;
contentClass?: string;
sideOffset?: number;
children: Snippet;
} = $props();
</script>
<Tooltip.Root delayDuration={delay} disableHoverableContent={passthrough}>
<Tooltip.Trigger>
{@render children()}
{#snippet child({ props })}
<span class={triggerClass} {...props}>
{@render children()}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
{side}
sideOffset={6}
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md whitespace-pre-line max-w-max"
{sideOffset}
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
>
{text}
</Tooltip.Content>
@@ -3,6 +3,7 @@ import { Combobox } from "bits-ui";
import { Check, ChevronsUpDown } from "@lucide/svelte";
import { fly } from "svelte/transition";
import type { Subject } from "$lib/api";
import { formatNumber } from "$lib/utils";
let {
subjects,
@@ -92,7 +93,7 @@ $effect(() => {
</span>
{/each}
{#if !open && overflowCount > 0}
<span class="text-xs text-muted-foreground shrink-0">+{overflowCount}</span>
<span class="text-xs text-muted-foreground shrink-0">+{formatNumber(overflowCount)}</span>
{/if}
{/if}
<Combobox.Input
@@ -39,7 +39,7 @@ export function createSvelteTable<TData extends RowData>(options: TableOptions<T
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStateChange: (updater: any) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater as Partial<TableState>);
else state = { ...state, ...(updater as Partial<TableState>) };
options.onStateChange?.(updater);
},
+3
View File
@@ -193,6 +193,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
rmpLegacyId: null,
},
{
bannerId: "2",
@@ -201,6 +202,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: true,
rmpRating: null,
rmpNumRatings: null,
rmpLegacyId: null,
},
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("B");
@@ -214,6 +216,7 @@ describe("getPrimaryInstructor", () => {
isPrimary: false,
rmpRating: null,
rmpNumRatings: null,
rmpLegacyId: null,
},
];
expect(getPrimaryInstructor(instructors)?.displayName).toBe("A");
+43 -5
View File
@@ -362,11 +362,49 @@ export function seatsDotColor(course: CourseResponse): string {
return "bg-green-500";
}
/** Text color class for a RateMyProfessors rating */
export function ratingColor(rating: number): string {
if (rating >= 4.0) return "text-status-green";
if (rating >= 3.0) return "text-yellow-500";
return "text-status-red";
/** Minimum number of ratings needed to consider RMP data reliable */
export const RMP_CONFIDENCE_THRESHOLD = 7;
/** RMP professor page URL from legacy ID */
export function rmpUrl(legacyId: number): string {
return `https://www.ratemyprofessors.com/professor/${legacyId}`;
}
/**
* Smooth OKLCH color + text-shadow for a RateMyProfessors rating.
*
* Three-stop gradient interpolated in OKLCH:
* 1.0 → red, 3.0 → amber, 5.0 → green
* with separate light/dark mode tuning.
*/
export function ratingStyle(rating: number, isDark: boolean): string {
const clamped = Math.max(1, Math.min(5, rating));
// OKLCH stops: [lightness, chroma, hue]
const stops: { light: [number, number, number]; dark: [number, number, number] }[] = [
{ light: [0.63, 0.2, 25], dark: [0.7, 0.19, 25] }, // 1.0 red
{ light: [0.7, 0.16, 85], dark: [0.78, 0.15, 85] }, // 3.0 amber
{ light: [0.65, 0.2, 145], dark: [0.72, 0.19, 145] }, // 5.0 green
];
let t: number;
let fromIdx: number;
if (clamped <= 3) {
t = (clamped - 1) / 2;
fromIdx = 0;
} else {
t = (clamped - 3) / 2;
fromIdx = 1;
}
const from = isDark ? stops[fromIdx].dark : stops[fromIdx].light;
const to = isDark ? stops[fromIdx + 1].dark : stops[fromIdx + 1].light;
const l = from[0] + (to[0] - from[0]) * t;
const c = from[1] + (to[1] - from[1]) * t;
const h = from[2] + (to[2] - from[2]) * t;
return `color: oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)}); text-shadow: 0 0 4px oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)} / 0.3);`;
}
/** Format credit hours display */
+13
View File
@@ -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");
}
+131
View File
@@ -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");
});
});
+86
View File
@@ -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;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { beforeNavigate } from "$app/navigation";
export type NavDirection = "left" | "right" | "fade";
/** Sidebar nav order — indexes determine slide direction for same-depth siblings */
const SIDEBAR_NAV_ORDER = [
"/profile",
"/settings",
"/admin",
"/admin/jobs",
"/admin/audit",
"/admin/users",
];
function getDepth(path: string): number {
return path.replace(/\/$/, "").split("/").filter(Boolean).length;
}
function getSidebarIndex(path: string): number {
return SIDEBAR_NAV_ORDER.indexOf(path);
}
function computeDirection(from: string, to: string): NavDirection {
const fromDepth = getDepth(from);
const toDepth = getDepth(to);
if (toDepth > fromDepth) return "right";
if (toDepth < fromDepth) return "left";
// Same depth — use sidebar ordering if both are sidebar routes
const fromIdx = getSidebarIndex(from);
const toIdx = getSidebarIndex(to);
if (fromIdx >= 0 && toIdx >= 0) {
return toIdx > fromIdx ? "right" : "left";
}
return "fade";
}
class NavigationStore {
direction: NavDirection = $state("fade");
}
export const navigationStore = new NavigationStore();
/** Call once from root layout to start tracking navigation direction */
export function initNavigation() {
beforeNavigate(({ from, to }) => {
if (!from?.url || !to?.url) return;
navigationStore.direction = computeDirection(from.url.pathname, to.url.pathname);
});
}
+53 -44
View File
@@ -1,69 +1,78 @@
/**
* 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.
* Format a duration in milliseconds as a compact human-readable string.
*
* 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 formatDuration(ms: number): string {
const totalSeconds = Math.floor(Math.abs(ms) / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const totalMinutes = Math.floor(totalSeconds / 60);
if (totalMinutes < 60) {
const secs = totalSeconds % 60;
return `${totalMinutes}m ${secs}s`;
}
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) {
const mins = totalMinutes % 60;
return `${totalHours}h ${mins}m`;
}
const days = Math.floor(totalHours / 24);
return `${days}d`;
}
/**
* Compute a compact relative time string and the interval until it next changes.
*
* Uses {@link formatDuration} for the text, plus computes the optimal refresh
* interval so callers can schedule the next update efficiently.
*/
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) {
const remainder = 1000 - (diffMs % 1000);
return {
text: seconds === 1 ? "1 second ago" : `${seconds} seconds ago`,
nextUpdateMs: remainder || 1000,
};
}
const text = formatDuration(diffMs);
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
// Update when the next minute boundary is crossed
// Compute optimal next-update interval based on the current tier
const totalMinutes = Math.floor(totalSeconds / 60);
const totalHours = Math.floor(totalMinutes / 60);
let nextUpdateMs: number;
if (totalHours >= 24) {
const msIntoCurrentDay = diffMs % 86_400_000;
nextUpdateMs = 86_400_000 - msIntoCurrentDay || 86_400_000;
} else if (totalMinutes >= 60) {
const msIntoCurrentMinute = diffMs % 60_000;
const msUntilNextMinute = 60_000 - msIntoCurrentMinute;
return {
text: minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`,
nextUpdateMs: msUntilNextMinute || 60_000,
};
nextUpdateMs = 60_000 - msIntoCurrentMinute || 60_000;
} else {
nextUpdateMs = 1000 - (diffMs % 1000) || 1000;
}
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 msIntoCurrentDay = diffMs % 86_400_000;
const msUntilNextDay = 86_400_000 - msIntoCurrentDay;
return {
text: days === 1 ? "1 day ago" : `${days} days ago`,
nextUpdateMs: msUntilNextDay || 86_400_000,
};
return { text, nextUpdateMs };
}
+23
View File
@@ -8,3 +8,26 @@ export function cn(...inputs: ClassValue[]) {
/** Shared tooltip content styling for bits-ui Tooltip.Content */
export const tooltipContentClass =
"z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72";
export interface FormatNumberOptions {
/** Include sign for positive numbers (default: false) */
sign?: boolean;
/** Maximum fraction digits (default: 0 for integers) */
maximumFractionDigits?: number;
}
/**
* Format a number with locale-aware thousands separators.
* Uses browser locale via Intl.NumberFormat.
*/
export function formatNumber(num: number, options: FormatNumberOptions = {}): string {
const { sign = false, maximumFractionDigits = 0 } = options;
const formatted = new Intl.NumberFormat(undefined, {
maximumFractionDigits,
}).format(num);
if (sign && num >= 0) {
return `+${formatted}`;
}
return formatted;
}
+210
View File
@@ -0,0 +1,210 @@
import type { ScrapeJob } from "$lib/api";
export type ScrapeJobStatus = "processing" | "staleLock" | "exhausted" | "scheduled" | "pending";
export type ScrapeJobEvent =
| { type: "init"; jobs: ScrapeJob[] }
| { type: "jobCreated"; job: ScrapeJob }
| { type: "jobLocked"; id: number; lockedAt: string; status: ScrapeJobStatus }
| { type: "jobCompleted"; id: number }
| {
type: "jobRetried";
id: number;
retryCount: number;
queuedAt: string;
status: ScrapeJobStatus;
}
| { type: "jobExhausted"; id: number }
| { type: "jobDeleted"; id: number };
export type ConnectionState = "connected" | "reconnecting" | "disconnected";
const PRIORITY_ORDER: Record<string, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
};
const MAX_RECONNECT_DELAY = 30_000;
const MAX_RECONNECT_ATTEMPTS = 10;
function sortJobs(jobs: Iterable<ScrapeJob>): ScrapeJob[] {
return Array.from(jobs).sort((a, b) => {
const pa = PRIORITY_ORDER[a.priority.toLowerCase()] ?? 2;
const pb = PRIORITY_ORDER[b.priority.toLowerCase()] ?? 2;
if (pa !== pb) return pa - pb;
return new Date(a.executeAt).getTime() - new Date(b.executeAt).getTime();
});
}
export class ScrapeJobsStore {
private ws: WebSocket | null = null;
private jobs = new Map<number, ScrapeJob>();
private _connectionState: ConnectionState = "disconnected";
private _initialized = false;
private onUpdate: () => void;
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionalClose = false;
/** Cached sorted array, invalidated on data mutations. */
private cachedJobs: ScrapeJob[] = [];
private cacheDirty = false;
constructor(onUpdate: () => void) {
this.onUpdate = onUpdate;
}
getJobs(): ScrapeJob[] {
if (this.cacheDirty) {
this.cachedJobs = sortJobs(this.jobs.values());
this.cacheDirty = false;
}
return this.cachedJobs;
}
getConnectionState(): ConnectionState {
return this._connectionState;
}
isInitialized(): boolean {
return this._initialized;
}
connect(): void {
this.intentionalClose = false;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/api/admin/scrape-jobs/ws`;
try {
this.ws = new WebSocket(url);
} catch {
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this._connectionState = "connected";
this.reconnectAttempts = 0;
this.onUpdate();
};
this.ws.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data as string) as ScrapeJobEvent;
this.handleEvent(parsed);
} catch {
// Ignore malformed messages
}
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// onclose will fire after onerror, so reconnect is handled there
};
}
handleEvent(event: ScrapeJobEvent): void {
switch (event.type) {
case "init":
this.jobs.clear();
for (const job of event.jobs) {
this.jobs.set(job.id, job);
}
this._initialized = true;
break;
case "jobCreated":
this.jobs.set(event.job.id, event.job);
break;
case "jobLocked": {
const job = this.jobs.get(event.id);
if (job) {
this.jobs.set(event.id, { ...job, lockedAt: event.lockedAt, status: event.status });
}
break;
}
case "jobCompleted":
this.jobs.delete(event.id);
break;
case "jobRetried": {
const job = this.jobs.get(event.id);
if (job) {
this.jobs.set(event.id, {
...job,
retryCount: event.retryCount,
queuedAt: event.queuedAt,
status: event.status,
lockedAt: null,
});
}
break;
}
case "jobExhausted": {
const job = this.jobs.get(event.id);
if (job) {
this.jobs.set(event.id, { ...job, status: "exhausted" });
}
break;
}
case "jobDeleted":
this.jobs.delete(event.id);
break;
}
this.cacheDirty = true;
this.onUpdate();
}
disconnect(): void {
this.intentionalClose = true;
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this._connectionState = "disconnected";
this.onUpdate();
}
resync(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "resync" }));
}
}
/** Attempt to reconnect after being disconnected. Resets attempt counter. */
retry(): void {
this.reconnectAttempts = 0;
this._connectionState = "reconnecting";
this.onUpdate();
this.connect();
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
this._connectionState = "disconnected";
this.onUpdate();
return;
}
this._connectionState = "reconnecting";
this.onUpdate();
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, MAX_RECONNECT_DELAY);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
}
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { authStore } from "$lib/auth.svelte";
import PageTransition from "$lib/components/PageTransition.svelte";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import {
ClipboardList,
FileText,
LayoutDashboard,
LogOut,
Settings,
User,
Users,
} from "@lucide/svelte";
import { onMount, tick } from "svelte";
let { children } = $props();
// Track boundary reset function so navigation can auto-clear errors
let boundaryReset = $state<(() => void) | null>(null);
let errorPathname = $state<string | null>(null);
function onBoundaryError(e: unknown, reset: () => void) {
console.error("[page boundary]", e);
boundaryReset = reset;
errorPathname = page.url.pathname;
}
// Auto-reset the boundary only when the user navigates away from the errored page
$effect(() => {
const currentPath = page.url.pathname;
if (boundaryReset && errorPathname && currentPath !== errorPathname) {
const reset = boundaryReset;
boundaryReset = null;
errorPathname = null;
tick().then(() => reset());
}
});
onMount(async () => {
if (authStore.isLoading) {
await authStore.init();
}
});
$effect(() => {
if (authStore.state.mode === "unauthenticated") {
goto("/login");
}
});
const userItems = [
{ href: "/profile", label: "Profile", icon: User },
{ href: "/settings", label: "Settings", icon: Settings },
];
const adminItems = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard },
{ href: "/admin/jobs", label: "Scrape Jobs", icon: ClipboardList },
{ href: "/admin/audit", label: "Audit Log", icon: FileText },
{ href: "/admin/users", label: "Users", icon: Users },
];
function isActive(href: string): boolean {
if (href === "/admin") return page.url.pathname === "/admin";
return page.url.pathname.startsWith(href);
}
</script>
{#if authStore.isLoading}
<div class="flex flex-col items-center p-5 pt-2">
<div class="w-full max-w-6xl">
<p class="text-muted-foreground py-12 text-center text-sm">Loading...</p>
</div>
</div>
{:else if !authStore.isAuthenticated}
<div class="flex flex-col items-center p-5 pt-2">
<div class="w-full max-w-6xl">
<p class="text-muted-foreground py-12 text-center text-sm">Redirecting to login...</p>
</div>
</div>
{:else}
<div class="flex flex-col items-center p-5 pt-2">
<div class="w-full max-w-6xl flex gap-8">
<!-- Inline sidebar -->
<aside class="w-48 shrink-0 pt-1">
{#if authStore.user}
<div class="mb-4 px-2">
<p class="text-sm font-medium text-foreground">{authStore.user.discordUsername}</p>
</div>
{/if}
<nav class="flex flex-col gap-0.5">
<span class="px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60 mb-0.5">User</span>
{#each userItems as item}
<a
href={item.href}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline transition-colors
{isActive(item.href)
? 'text-foreground bg-muted font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<item.icon size={15} strokeWidth={2} />
{item.label}
</a>
{/each}
{#if authStore.isAdmin}
<div class="my-2 mx-2 border-t border-border"></div>
<span class="px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60 mb-0.5">Admin</span>
{#each adminItems as item}
<a
href={item.href}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm no-underline transition-colors
{isActive(item.href)
? 'text-foreground bg-muted font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
>
<item.icon size={15} strokeWidth={2} />
{item.label}
</a>
{/each}
{/if}
<div class="my-2 mx-2 border-t border-border"></div>
<button
onclick={() => authStore.logout()}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer
bg-transparent border-none text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<LogOut size={15} strokeWidth={2} />
Sign Out
</button>
</nav>
</aside>
<!-- Content -->
<main class="flex-1 min-w-0">
<svelte:boundary onerror={onBoundaryError}>
<PageTransition key={page.url.pathname} axis="vertical">
{@render children()}
</PageTransition>
{#snippet failed(error, reset)}
<ErrorBoundaryFallback title="Page error" {error} {reset} />
{/snippet}
</svelte:boundary>
</main>
</div>
</div>
{/if}
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import { onMount } from "svelte";
import { client, type AdminStatus } from "$lib/api";
import { formatNumber } from "$lib/utils";
let status = $state<AdminStatus | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
status = await client.getAdminStatus();
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load status";
}
});
</script>
<h1 class="mb-4 text-lg font-semibold text-foreground">Dashboard</h1>
{#if error}
<p class="text-destructive">{error}</p>
{:else if !status}
<p class="text-muted-foreground">Loading...</p>
{:else}
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="bg-card border-border rounded-lg border p-4">
<p class="text-muted-foreground text-sm">Users</p>
<p class="text-3xl font-bold">{formatNumber(status.userCount)}</p>
</div>
<div class="bg-card border-border rounded-lg border p-4">
<p class="text-muted-foreground text-sm">Active Sessions</p>
<p class="text-3xl font-bold">{formatNumber(status.sessionCount)}</p>
</div>
<div class="bg-card border-border rounded-lg border p-4">
<p class="text-muted-foreground text-sm">Courses</p>
<p class="text-3xl font-bold">{formatNumber(status.courseCount)}</p>
</div>
<div class="bg-card border-border rounded-lg border p-4">
<p class="text-muted-foreground text-sm">Scrape Jobs</p>
<p class="text-3xl font-bold">{formatNumber(status.scrapeJobCount)}</p>
</div>
</div>
<h2 class="mt-6 mb-3 text-sm font-semibold text-foreground">Services</h2>
<div class="bg-card border-border rounded-lg border">
{#each status.services as service}
<div class="border-border flex items-center justify-between border-b px-4 py-3 last:border-b-0">
<span class="font-medium">{service.name}</span>
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900 dark:text-green-200">
{service.status}
</span>
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,422 @@
<script module lang="ts">
import type { AuditLogResponse } from "$lib/api";
// Persisted across navigation so returning to the page shows cached data
// instead of a skeleton loader. Timers are instance-scoped and restart on mount.
let data = $state<AuditLogResponse | null>(null);
let error = $state<string | null>(null);
let refreshError = $state(false);
let refreshInterval = 5_000;
</script>
<script lang="ts">
import { type AuditLogEntry, 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 { formatNumber } from "$lib/utils";
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 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 refreshTimer: ReturnType<typeof setTimeout> | undefined;
// 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 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">{formatNumber(change.delta, { sign: true })}<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">
{formatNumber(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}
@@ -0,0 +1,526 @@
<script lang="ts">
import { type ScrapeJob, client } from "$lib/api";
import { FlexRender, createSvelteTable } from "$lib/components/ui/data-table/index.js";
import { formatAbsoluteDate } from "$lib/date";
import { formatDuration } from "$lib/time";
import { ArrowDown, ArrowUp, ArrowUpDown, TriangleAlert } from "@lucide/svelte";
import {
type ColumnDef,
type SortingState,
type Updater,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { onMount } from "svelte";
import { type ConnectionState, ScrapeJobsStore } from "$lib/ws";
let jobs = $state<ScrapeJob[]>([]);
let connectionState = $state<ConnectionState>("disconnected");
let initialized = $state(false);
let error = $state<string | null>(null);
let sorting: SortingState = $state([]);
let tick = $state(0);
let subjectMap = $state(new Map<string, string>());
let store: ScrapeJobsStore | undefined;
// Shared tooltip state — single tooltip for all timing cells via event delegation
let tooltipText = $state<string | null>(null);
let tooltipX = $state(0);
let tooltipY = $state(0);
function showTooltip(event: MouseEvent) {
const target = (event.target as HTMLElement).closest<HTMLElement>("[data-timing-tooltip]");
if (!target) return;
tooltipText = target.dataset.timingTooltip ?? null;
tooltipX = event.clientX;
tooltipY = event.clientY;
}
function moveTooltip(event: MouseEvent) {
if (tooltipText === null) return;
const target = (event.target as HTMLElement).closest<HTMLElement>("[data-timing-tooltip]");
if (!target) {
tooltipText = null;
return;
}
tooltipText = target.dataset.timingTooltip ?? null;
tooltipX = event.clientX;
tooltipY = event.clientY;
}
function hideTooltip() {
tooltipText = null;
}
onMount(() => {
// Tick every second for live time displays
const tickInterval = setInterval(() => {
tick++;
}, 1000);
// Load subject reference data
client
.getReference("subject")
.then((entries) => {
const map = new Map<string, string>();
for (const entry of entries) {
map.set(entry.code, entry.description);
}
subjectMap = map;
})
.catch(() => {
// Subject lookup is best-effort
});
// Initialize WebSocket store
store = new ScrapeJobsStore(() => {
if (!store) return;
connectionState = store.getConnectionState();
initialized = store.isInitialized();
// getJobs() returns a cached array when unchanged, so only reassign
// when the reference differs to avoid triggering reactive table rebuilds.
const next = store.getJobs();
if (next !== jobs) jobs = next;
});
store.connect();
return () => {
clearInterval(tickInterval);
store?.disconnect();
};
});
function handleSortingChange(updater: Updater<SortingState>) {
sorting = typeof updater === "function" ? updater(sorting) : updater;
}
// --- Helper functions ---
function formatJobDetails(job: ScrapeJob, subjects: Map<string, string>): string {
const payload = job.targetPayload as Record<string, unknown>;
switch (job.targetType) {
case "Subject": {
const code = payload.subject as string;
const desc = subjects.get(code);
return desc ? `${code} \u2014 ${desc}` : code;
}
case "CrnList": {
const crns = payload.crns as string[];
return `${crns.length} CRNs`;
}
case "SingleCrn":
return `CRN ${payload.crn as string}`;
case "CourseRange":
return `${payload.subject as string} ${payload.low as number}\u2013${payload.high as number}`;
default:
return JSON.stringify(payload);
}
}
function priorityColor(priority: string): string {
const p = priority.toLowerCase();
if (p === "urgent" || p === "critical") return "text-red-500";
if (p === "high") return "text-orange-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";
}
function statusColor(status: string): { text: string; dot: string } {
switch (status) {
case "processing":
return { text: "text-blue-500", dot: "bg-blue-500" };
case "pending":
return { text: "text-green-500", dot: "bg-green-500" };
case "scheduled":
return { text: "text-muted-foreground", dot: "bg-muted-foreground" };
case "staleLock":
return { text: "text-red-500", dot: "bg-red-500" };
case "exhausted":
return { text: "text-red-500", dot: "bg-red-500" };
default:
return { text: "text-muted-foreground", dot: "bg-muted-foreground" };
}
}
function formatStatusLabel(status: string): string {
// Convert camelCase to separate words, capitalize first letter
return status.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^\w/, (c) => c.toUpperCase());
}
function lockDurationColor(ms: number): string {
const minutes = ms / 60_000;
if (minutes >= 8) return "text-red-500";
if (minutes >= 5) return "text-amber-500";
return "text-foreground";
}
function overdueDurationColor(ms: number): string {
const minutes = ms / 60_000;
if (minutes >= 5) return "text-red-500";
return "text-amber-500";
}
// --- Table columns ---
const columns: ColumnDef<ScrapeJob, unknown>[] = [
{
id: "id",
accessorKey: "id",
header: "ID",
enableSorting: false,
},
{
id: "status",
accessorKey: "status",
header: "Status",
enableSorting: true,
sortingFn: (rowA, rowB) => {
const order: Record<string, number> = {
processing: 0,
staleLock: 1,
pending: 2,
scheduled: 3,
exhausted: 4,
};
const a = order[rowA.original.status] ?? 3;
const b = order[rowB.original.status] ?? 3;
return a - b;
},
},
{
id: "targetType",
accessorKey: "targetType",
header: "Type",
enableSorting: false,
},
{
id: "details",
accessorFn: () => "",
header: "Details",
enableSorting: false,
},
{
id: "priority",
accessorKey: "priority",
header: "Priority",
enableSorting: true,
sortingFn: (rowA, rowB) => {
const order: Record<string, number> = {
critical: 0,
urgent: 0,
high: 1,
normal: 2,
medium: 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: "timing",
accessorFn: (row) => {
if (row.lockedAt) return Date.now() - new Date(row.lockedAt).getTime();
return Date.now() - new Date(row.queuedAt).getTime();
},
header: "Timing",
enableSorting: true,
},
];
const table = createSvelteTable({
get data() {
return 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-6",
status: "w-20",
targetType: "w-16",
details: "w-32",
priority: "w-16",
timing: "w-32",
};
// Unified timing display: shows the most relevant duration for the job's current state.
// Uses _tick dependency so Svelte re-evaluates every second.
function getTimingDisplay(
job: ScrapeJob,
_tick: number
): { text: string; colorClass: string; icon: "warning" | "none"; tooltip: string } {
const now = Date.now();
const queuedTime = new Date(job.queuedAt).getTime();
const executeTime = new Date(job.executeAt).getTime();
if (job.status === "processing" || job.status === "staleLock") {
const lockedTime = job.lockedAt ? new Date(job.lockedAt).getTime() : now;
const processingMs = now - lockedTime;
const waitedMs = lockedTime - queuedTime;
const prefix = job.status === "staleLock" ? "stale" : "processing";
const colorClass =
job.status === "staleLock" ? "text-red-500" : lockDurationColor(processingMs);
const tooltipLines = [
`Queued: ${formatAbsoluteDate(job.queuedAt)}`,
`Waited: ${formatDuration(Math.max(0, waitedMs))}`,
];
if (job.lockedAt) {
tooltipLines.push(`Locked: ${formatAbsoluteDate(job.lockedAt)}`);
}
tooltipLines.push(
`${job.status === "staleLock" ? "Stale for" : "Processing"}: ${formatDuration(processingMs)}`,
);
return {
text: `${prefix} ${formatDuration(processingMs)}`,
colorClass,
icon: job.status === "staleLock" ? "warning" : "none",
tooltip: tooltipLines.join("\n"),
};
}
if (job.status === "exhausted") {
const tooltipLines = [
`Queued: ${formatAbsoluteDate(job.queuedAt)}`,
`Retries: ${job.retryCount}/${job.maxRetries} exhausted`,
];
return {
text: "exhausted",
colorClass: "text-red-500",
icon: "warning",
tooltip: tooltipLines.join("\n"),
};
}
// Scheduled (future execute_at)
const executeAtDiff = now - executeTime;
if (job.status === "scheduled" || executeAtDiff < 0) {
const tooltipLines = [
`Queued: ${formatAbsoluteDate(job.queuedAt)}`,
`Executes: ${formatAbsoluteDate(job.executeAt)}`,
];
return {
text: `in ${formatDuration(Math.abs(executeAtDiff))}`,
colorClass: "text-muted-foreground",
icon: "none",
tooltip: tooltipLines.join("\n"),
};
}
// Pending (overdue — execute_at is in the past, waiting to be picked up)
const waitingMs = now - queuedTime;
const tooltipLines = [
`Queued: ${formatAbsoluteDate(job.queuedAt)}`,
`Waiting: ${formatDuration(waitingMs)}`,
];
return {
text: `waiting ${formatDuration(waitingMs)}`,
colorClass: overdueDurationColor(waitingMs),
icon: "warning",
tooltip: tooltipLines.join("\n"),
};
}
</script>
<div class="flex items-center justify-between mb-4">
<h1 class="text-lg font-semibold text-foreground">Scrape Jobs</h1>
<div class="flex items-center gap-2 text-sm">
{#if connectionState === "connected"}
<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">Live</span>
</span>
{:else if connectionState === "reconnecting"}
<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">Reconnecting...</span>
</span>
{:else}
<span class="inline-flex items-center gap-2">
<span class="inline-flex items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full bg-red-500"></span>
<span class="text-red-500">Disconnected</span>
</span>
<button
class="rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-foreground hover:bg-muted/80 transition-colors"
onclick={() => store?.retry()}
>
Retry
</button>
</span>
{/if}
</div>
</div>
{#if error}
<p class="text-destructive">{error}</p>
{:else}
<div class="bg-card border-border overflow-hidden rounded-lg border">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<table
class="w-full border-collapse text-xs"
onmouseenter={showTooltip}
onmousemove={moveTooltip}
onmouseleave={hideTooltip}
>
<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-3 py-2.5 font-medium whitespace-nowrap"
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>
{#if !initialized}
<tbody>
{#each Array(5) as _}
<tr class="border-b border-border">
{#each columns as col}
<td class="px-3 py-2.5">
<div
class="h-3.5 rounded bg-muted animate-pulse {skeletonWidths[col.id ?? ''] ?? 'w-20'}"
></div>
</td>
{/each}
</tr>
{/each}
</tbody>
{:else if 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 (row.id)}
{@const job = row.original}
{@const sc = statusColor(job.status)}
{@const timingDisplay = getTimingDisplay(job, tick)}
<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-3 py-2.5 tabular-nums text-muted-foreground/70 w-12">{job.id}</td>
{:else if colId === "status"}
<td class="px-3 py-2.5 whitespace-nowrap">
<span class="inline-flex items-center gap-1.5">
<span class="size-1.5 shrink-0 rounded-full {sc.dot}"></span>
<span class="flex flex-col leading-tight">
<span class={sc.text}>{formatStatusLabel(job.status)}</span>
{#if job.maxRetries > 0}
<span class="text-[10px] {retryColor(job.retryCount, job.maxRetries)}">
{job.retryCount}/{job.maxRetries} retries
</span>
{/if}
</span>
</span>
</td>
{:else if colId === "targetType"}
<td class="px-3 py-2.5 whitespace-nowrap">
<span
class="inline-flex items-center rounded-md bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
>
{job.targetType}
</span>
</td>
{:else if colId === "details"}
<td class="px-3 py-2.5 max-w-48 truncate text-muted-foreground" title={formatJobDetails(job, subjectMap)}>
{formatJobDetails(job, subjectMap)}
</td>
{:else if colId === "priority"}
<td class="px-3 py-2.5 whitespace-nowrap">
<span class="font-medium capitalize {priorityColor(job.priority)}">
{job.priority}
</span>
</td>
{:else if colId === "timing"}
<td class="px-3 py-2.5 whitespace-nowrap">
<span
class="inline-flex items-center gap-1.5 tabular-nums text-foreground"
data-timing-tooltip={timingDisplay.tooltip}
>
<span class="size-3.5 shrink-0 inline-flex items-center justify-center {timingDisplay.colorClass}">
{#if timingDisplay.icon === "warning"}
<TriangleAlert class="size-3.5" />
{/if}
</span>
{timingDisplay.text}
</span>
</td>
{/if}
{/each}
</tr>
{/each}
</tbody>
{/if}
</table>
</div>
{#if tooltipText !== null}
<div
class="pointer-events-none fixed z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left"
style="left: {tooltipX + 12}px; top: {tooltipY + 12}px;"
>
{tooltipText}
</div>
{/if}
{/if}
@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from "svelte";
import { client } from "$lib/api";
import type { User } from "$lib/bindings";
import { Shield, ShieldOff } from "@lucide/svelte";
let users = $state<User[]>([]);
let error = $state<string | null>(null);
let updating = $state<string | null>(null);
onMount(async () => {
try {
users = await client.getAdminUsers();
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load users";
}
});
async function toggleAdmin(user: User) {
updating = user.discordId;
try {
const updated = await client.setUserAdmin(user.discordId, !user.isAdmin);
users = users.map((u) => (u.discordId === updated.discordId ? updated : u));
} catch (e) {
error = e instanceof Error ? e.message : "Failed to update user";
} finally {
updating = null;
}
}
</script>
<h1 class="mb-4 text-lg font-semibold text-foreground">Users</h1>
{#if error}
<p class="text-destructive mb-4">{error}</p>
{/if}
{#if users.length === 0 && !error}
<p class="text-muted-foreground">Loading...</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">Username</th>
<th class="px-4 py-3 text-left font-medium">Discord ID</th>
<th class="px-4 py-3 text-left font-medium">Admin</th>
<th class="px-4 py-3 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr class="border-border border-b last:border-b-0">
<td class="flex items-center gap-2 px-4 py-3">
{#if user.discordAvatarHash}
<img
src="https://cdn.discordapp.com/avatars/{user.discordId}/{user.discordAvatarHash}.png?size=32"
alt=""
class="h-6 w-6 rounded-full"
/>
{/if}
{user.discordUsername}
</td>
<td class="text-muted-foreground px-4 py-3 font-mono text-xs">{user.discordId}</td>
<td class="px-4 py-3">
{#if user.isAdmin}
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">Admin</span>
{:else}
<span class="text-muted-foreground text-xs">User</span>
{/if}
</td>
<td class="px-4 py-3">
<button
onclick={() => toggleAdmin(user)}
disabled={updating === user.discordId}
class="hover:bg-accent inline-flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors disabled:opacity-50"
>
{#if user.isAdmin}
<ShieldOff size={14} />
Remove Admin
{:else}
<Shield size={14} />
Make Admin
{/if}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import { authStore } from "$lib/auth.svelte";
</script>
<h1 class="mb-4 text-lg font-semibold text-foreground">Profile</h1>
{#if authStore.user}
<div class="bg-card border-border rounded-lg border p-4">
<div class="flex flex-col gap-3">
<div>
<p class="text-muted-foreground text-sm">Username</p>
<p class="font-medium">{authStore.user.discordUsername}</p>
</div>
<div>
<p class="text-muted-foreground text-sm">Discord ID</p>
<p class="font-medium font-mono text-sm">{authStore.user.discordId}</p>
</div>
<div>
<p class="text-muted-foreground text-sm">Role</p>
<p class="font-medium">{authStore.isAdmin ? "Admin" : "User"}</p>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,5 @@
<h1 class="mb-4 text-lg font-semibold text-foreground">Settings</h1>
<div class="bg-card border-border rounded-lg border p-4">
<p class="text-muted-foreground text-sm">No settings available yet.</p>
</div>
+32 -12
View File
@@ -1,14 +1,30 @@
<script lang="ts">
import "overlayscrollbars/overlayscrollbars.css";
import "./layout.css";
import { onMount } from "svelte";
import { Tooltip } from "bits-ui";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { page } from "$app/state";
import PageTransition from "$lib/components/PageTransition.svelte";
import NavBar from "$lib/components/NavBar.svelte";
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
import { initNavigation } from "$lib/stores/navigation.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import { Tooltip } from "bits-ui";
import ErrorBoundaryFallback from "$lib/components/ErrorBoundaryFallback.svelte";
import { onMount } from "svelte";
let { children } = $props();
const APP_PREFIXES = ["/profile", "/settings", "/admin"];
/**
* Coarsened key so sub-route navigation within the (app) layout group
* doesn't re-trigger the root page transition — the shared layout handles its own.
*/
let transitionKey = $derived(
APP_PREFIXES.some((p) => page.url.pathname.startsWith(p)) ? "/app" : page.url.pathname
);
initNavigation();
useOverlayScrollbars(() => document.body, {
scrollbars: {
autoHide: "leave",
@@ -18,17 +34,21 @@ useOverlayScrollbars(() => document.body, {
onMount(() => {
themeStore.init();
requestAnimationFrame(() => {
document.documentElement.classList.remove("no-transition");
});
});
</script>
<Tooltip.Provider>
<div class="fixed top-5 right-5 z-50">
<ThemeToggle />
</div>
<div class="flex min-h-screen flex-col">
<NavBar />
{@render children()}
<svelte:boundary onerror={(e) => console.error("[root boundary]", e)}>
<PageTransition key={transitionKey}>
{@render children()}
</PageTransition>
{#snippet failed(error, reset)}
<ErrorBoundaryFallback {error} {reset} />
{/snippet}
</svelte:boundary>
</div>
</Tooltip.Provider>
+14 -6
View File
@@ -10,6 +10,7 @@ import {
} from "$lib/api";
import type { SortingState } from "@tanstack/table-core";
import SearchFilters from "$lib/components/SearchFilters.svelte";
import SearchStatus, { type SearchMeta } from "$lib/components/SearchStatus.svelte";
import CourseTable from "$lib/components/CourseTable.svelte";
import Pagination from "$lib/components/Pagination.svelte";
import Footer from "$lib/components/Footer.svelte";
@@ -56,6 +57,7 @@ let subjectMap: Record<string, string> = $derived(
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
);
let searchResult: SearchResponse | null = $state(null);
let searchMeta: SearchMeta | null = $state(null);
let loading = $state(false);
let error = $state<string | null>(null);
@@ -169,6 +171,7 @@ async function performSearch(
if (sortDir && sortBy) params.set("sort_dir", sortDir);
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
const t0 = performance.now();
try {
searchResult = await client.searchCourses({
term,
@@ -180,6 +183,11 @@ async function performSearch(
sort_by: sortBy,
sort_dir: sortDir,
});
searchMeta = {
totalCount: searchResult.totalCount,
durationMs: performance.now() - t0,
timestamp: new Date(),
};
} catch (e) {
error = e instanceof Error ? e.message : "Search failed";
} finally {
@@ -193,13 +201,12 @@ function handlePageChange(newOffset: number) {
</script>
<div class="min-h-screen flex flex-col items-center p-5">
<div class="w-full max-w-6xl flex flex-col gap-6">
<!-- Title -->
<div class="text-center pt-8 pb-2">
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
</div>
<div class="w-full max-w-6xl flex flex-col gap-6 pt-2">
<!-- Filters -->
<!-- Search status + Filters -->
<div class="flex flex-col gap-1.5">
<SearchStatus meta={searchMeta} />
<!-- Filters -->
<SearchFilters
terms={data.terms}
{subjects}
@@ -208,6 +215,7 @@ function handlePageChange(newOffset: number) {
bind:query
bind:openOnly
/>
</div>
<!-- Results -->
{#if error}
+1 -4
View File
@@ -16,6 +16,7 @@ import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
import Footer from "$lib/components/Footer.svelte";
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
import { relativeTime } from "$lib/time";
import { formatNumber } from "$lib/utils";
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
const REQUEST_TIMEOUT = 10000;
@@ -105,10 +106,6 @@ const lastFetch = $derived(
const relativeLastFetchResult = $derived(lastFetch ? relativeTime(lastFetch, now) : null);
const relativeLastFetch = $derived(relativeLastFetchResult?.text ?? "");
function formatNumber(num: number): string {
return num.toLocaleString();
}
onMount(() => {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let requestTimeoutId: ReturnType<typeof setTimeout> | null = null;
+1 -9
View File
@@ -57,11 +57,8 @@
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
}
* {
border-color: var(--border);
}
body {
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
@@ -129,11 +126,6 @@ input[type="checkbox"]:checked::before {
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
html:not(.no-transition) body,
html:not(.no-transition) body * {
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
}
/* View Transitions API - disable default cross-fade so JS can animate clip-path */
::view-transition-old(root),
::view-transition-new(root) {
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { authStore } from "$lib/auth.svelte";
import { LogIn } from "@lucide/svelte";
</script>
<div class="flex flex-1 items-center justify-center pb-14">
<div class="w-full max-w-sm space-y-6 text-center">
<h1 class="text-3xl font-bold">Sign In</h1>
<p class="text-muted-foreground">Sign in with your Discord account to continue.</p>
<button
onclick={() => authStore.login()}
class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-[#5865F2] px-6 py-3 text-lg font-semibold text-white transition-colors hover:bg-[#4752C4]"
>
<LogIn size={20} />
Sign in with Discord
</button>
</div>
</div>
+1
View File
@@ -38,6 +38,7 @@ export default defineConfig({
target: "http://localhost:8080",
changeOrigin: true,
secure: false,
ws: true,
},
},
},