diff --git a/Cargo.lock b/Cargo.lock index f459ceb..b4b9f54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,7 +238,6 @@ dependencies = [ "http 1.3.1", "mime_guess", "num-format", - "once_cell", "poise", "rand 0.9.2", "rapidhash", @@ -1284,12 +1283,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" @@ -3544,20 +3537,12 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "bitflags 2.9.4", "bytes", - "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", - "http-body-util", - "http-range-header", - "httpdate", "iri-string", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 42faba7..d276d84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,10 +46,9 @@ tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } url = "2.5" governor = "0.10.1" -once_cell = "1.21.3" serde_path_to_error = "0.1.17" num-format = "0.4.4" -tower-http = { version = "0.6.0", features = ["fs", "cors", "trace", "timeout"] } +tower-http = { version = "0.6.0", features = ["cors", "trace", "timeout"] } rust-embed = { version = "8.0", features = ["include-exclude"], optional = true } mime_guess = { version = "2.0", optional = true } clap = { version = "4.5", features = ["derive"] } diff --git a/src/banner/models/meetings.rs b/src/banner/models/meetings.rs index 677bf25..7e65130 100644 --- a/src/banner/models/meetings.rs +++ b/src/banner/models/meetings.rs @@ -298,7 +298,7 @@ impl DateRange { /// Get the number of weeks between start and end dates pub fn weeks_duration(&self) -> u32 { let duration = self.end.signed_duration_since(self.start); - duration.num_weeks() as u32 + duration.num_weeks().max(0) as u32 } /// Check if a specific date falls within this range diff --git a/src/banner/query.rs b/src/banner/query.rs index ea710bd..f53551f 100644 --- a/src/banner/query.rs +++ b/src/banner/query.rs @@ -168,8 +168,9 @@ impl SearchQuery { } /// Sets the maximum number of results to return + /// Clamped to a maximum of 500 to prevent excessive API load pub fn max_results(mut self, max_results: i32) -> Self { - self.max_results = max_results; + self.max_results = max_results.clamp(1, 500); self } diff --git a/src/banner/session.rs b/src/banner/session.rs index b4ddbb9..47fb774 100644 --- a/src/banner/session.rs +++ b/src/banner/session.rs @@ -7,13 +7,12 @@ use cookie::Cookie; use dashmap::DashMap; use governor::state::InMemoryState; use governor::{Quota, RateLimiter}; -use once_cell::sync::Lazy; use rand::distr::{Alphanumeric, SampleString}; use reqwest_middleware::ClientWithMiddleware; use std::collections::{HashMap, VecDeque}; use std::ops::{Deref, DerefMut}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, Notify}; use tracing::{debug, info, trace}; @@ -23,9 +22,9 @@ const SESSION_EXPIRY: Duration = Duration::from_secs(25 * 60); // 25 minutes // A global rate limiter to ensure we only try to create one new session every 10 seconds, // preventing us from overwhelming the server with session creation requests. -static SESSION_CREATION_RATE_LIMITER: Lazy< +static SESSION_CREATION_RATE_LIMITER: LazyLock< RateLimiter, -> = Lazy::new(|| RateLimiter::direct(Quota::with_period(Duration::from_secs(10)).unwrap())); +> = LazyLock::new(|| RateLimiter::direct(Quota::with_period(Duration::from_secs(10)).unwrap())); /// Represents an active anonymous session within the Banner API. /// Identified by multiple persistent cookies, as well as a client-generated "unique session ID". @@ -63,16 +62,16 @@ pub fn nonce() -> String { impl BannerSession { /// Creates a new session - pub fn new(unique_session_id: &str, jsessionid: &str, ssb_cookie: &str) -> Result { + pub fn new(unique_session_id: &str, jsessionid: &str, ssb_cookie: &str) -> Self { let now = Instant::now(); - Ok(Self { + Self { created_at: now, last_activity: None, unique_session_id: unique_session_id.to_string(), jsessionid: jsessionid.to_string(), ssb_cookie: ssb_cookie.to_string(), - }) + } } /// Returns the unique session ID @@ -124,47 +123,46 @@ mod tests { use super::*; #[test] - fn test_new_session_returns_ok() { + fn test_new_session_creates_session() { let session = BannerSession::new("sess-1", "JSID123", "SSB456"); - assert!(session.is_ok()); - assert_eq!(session.unwrap().id(), "sess-1"); + assert_eq!(session.id(), "sess-1"); } #[test] fn test_fresh_session_not_expired() { - let session = BannerSession::new("sess-1", "JSID123", "SSB456").unwrap(); + let session = BannerSession::new("sess-1", "JSID123", "SSB456"); assert!(!session.is_expired()); } #[test] fn test_fresh_session_not_been_used() { - let session = BannerSession::new("sess-1", "JSID123", "SSB456").unwrap(); + let session = BannerSession::new("sess-1", "JSID123", "SSB456"); assert!(!session.been_used()); } #[test] fn test_touch_marks_used() { - let mut session = BannerSession::new("sess-1", "JSID123", "SSB456").unwrap(); + let mut session = BannerSession::new("sess-1", "JSID123", "SSB456"); session.touch(); assert!(session.been_used()); } #[test] fn test_touched_session_not_expired() { - let mut session = BannerSession::new("sess-1", "JSID123", "SSB456").unwrap(); + let mut session = BannerSession::new("sess-1", "JSID123", "SSB456"); session.touch(); assert!(!session.is_expired()); } #[test] fn test_cookie_format() { - let session = BannerSession::new("sess-1", "JSID123", "SSB456").unwrap(); + let session = BannerSession::new("sess-1", "JSID123", "SSB456"); assert_eq!(session.cookie(), "JSESSIONID=JSID123; SSB_COOKIE=SSB456"); } #[test] fn test_id_returns_unique_session_id() { - let session = BannerSession::new("my-unique-id", "JSID123", "SSB456").unwrap(); + let session = BannerSession::new("my-unique-id", "JSID123", "SSB456"); assert_eq!(session.id(), "my-unique-id"); } @@ -454,7 +452,7 @@ impl SessionPool { self.select_term(&term.to_string(), &unique_session_id, &cookie_header) .await?; - BannerSession::new(&unique_session_id, jsessionid, ssb_cookie) + Ok(BannerSession::new(&unique_session_id, jsessionid, ssb_cookie)) } /// Retrieves a list of terms from the Banner API. diff --git a/src/banner/util.rs b/src/banner/util.rs index 4e6faa7..5243ad2 100644 --- a/src/banner/util.rs +++ b/src/banner/util.rs @@ -2,5 +2,5 @@ /// Returns a browser-like user agent string. pub fn user_agent() -> &'static str { - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36" + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" } diff --git a/src/scraper/jobs/mod.rs b/src/scraper/jobs/mod.rs index 54c3104..c3dfcfc 100644 --- a/src/scraper/jobs/mod.rs +++ b/src/scraper/jobs/mod.rs @@ -5,58 +5,24 @@ use crate::data::models::TargetType; use crate::error::Result; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use std::fmt; +use thiserror::Error; /// Errors that can occur during job parsing -#[derive(Debug)] +#[derive(Debug, Error)] pub enum JobParseError { - InvalidJson(serde_json::Error), + #[error("Invalid JSON in job payload: {0}")] + InvalidJson(#[from] serde_json::Error), + #[error("Unsupported target type: {0:?}")] UnsupportedTargetType(TargetType), } -impl fmt::Display for JobParseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - JobParseError::InvalidJson(e) => write!(f, "Invalid JSON in job payload: {}", e), - JobParseError::UnsupportedTargetType(t) => { - write!(f, "Unsupported target type: {:?}", t) - } - } - } -} - -impl std::error::Error for JobParseError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - JobParseError::InvalidJson(e) => Some(e), - _ => None, - } - } -} - /// Errors that can occur during job processing -#[derive(Debug)] +#[derive(Debug, Error)] pub enum JobError { - Recoverable(anyhow::Error), // API failures, network issues - Unrecoverable(anyhow::Error), // Parse errors, corrupted data -} - -impl fmt::Display for JobError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - JobError::Recoverable(e) => write!(f, "Recoverable error: {}", e), - JobError::Unrecoverable(e) => write!(f, "Unrecoverable error: {}", e), - } - } -} - -impl std::error::Error for JobError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - JobError::Recoverable(e) => e.source(), - JobError::Unrecoverable(e) => e.source(), - } - } + #[error("Recoverable error: {0}")] + Recoverable(#[source] anyhow::Error), + #[error("Unrecoverable error: {0}")] + Unrecoverable(#[source] anyhow::Error), } /// Common trait interface for all job types diff --git a/src/web/assets.rs b/src/web/assets.rs index 42a7a0a..d91190c 100644 --- a/src/web/assets.rs +++ b/src/web/assets.rs @@ -4,10 +4,10 @@ //! at compile time using rust-embed. use dashmap::DashMap; -use once_cell::sync::Lazy; use rapidhash::v3::rapidhash_v3; use rust_embed::RustEmbed; use std::fmt; +use std::sync::LazyLock; /// Embedded web assets from the dist directory #[derive(RustEmbed)] @@ -65,7 +65,7 @@ impl AssetMetadata { } /// Global cache for asset metadata to avoid repeated calculations -static ASSET_CACHE: Lazy> = Lazy::new(DashMap::new); +static ASSET_CACHE: LazyLock> = LazyLock::new(DashMap::new); /// Get cached asset metadata for a file path, caching on-demand /// Returns AssetMetadata containing MIME type and RapidHash hash diff --git a/web/src/App.css b/web/src/App.css index d5e3ee6..c2fc769 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -20,6 +20,26 @@ animation: pulse 2s ease-in-out infinite; } +/* Theme toggle button */ +.theme-toggle { + cursor: pointer; + background-color: transparent; + border: none; + margin: 4px; + padding: 7px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--gray-11); + transition: background-color 0.2s, color 0.2s; + transform: scale(1.25); +} + +.theme-toggle:hover { + background-color: var(--gray-4); +} + /* Screen reader only text */ .sr-only { position: absolute; diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx index c9d7a20..bde2be0 100644 --- a/web/src/components/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle.tsx @@ -1,6 +1,6 @@ -import { useTheme } from "next-themes"; import { Button } from "@radix-ui/themes"; -import { Sun, Moon, Monitor } from "lucide-react"; +import { Monitor, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; import { useMemo } from "react"; export function ThemeToggle() { @@ -28,31 +28,7 @@ export function ThemeToggle() { }, [nextTheme]); return ( - diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index ec098cc..ac24fe1 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { BannerApiClient } from "./api"; // Mock fetch @@ -31,12 +31,12 @@ describe("BannerApiClient", () => { it("should fetch status data", async () => { const mockStatus = { - status: "active", + status: "active" as const, version: "0.3.4", commit: "abc1234", services: { - web: { name: "web", status: "active" }, - database: { name: "database", status: "connected" }, + web: { name: "web", status: "active" as const }, + database: { name: "database", status: "connected" as const }, }, }; diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index ed0371f..1fd160a 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,21 +1,21 @@ +import { Card, Flex, Skeleton, Text, Tooltip } from "@radix-ui/themes"; import { createFileRoute } from "@tanstack/react-router"; -import { useState, useEffect } from "react"; -import { client, type StatusResponse, type Status } from "../lib/api"; -import { Card, Flex, Text, Tooltip, Skeleton } from "@radix-ui/themes"; import { - CheckCircle, - XCircle, - Clock, + Activity, Bot, + CheckCircle, + Circle, + Clock, Globe, Hourglass, - Activity, MessageCircle, - Circle, WifiOff, + XCircle, } from "lucide-react"; +import { useEffect, useState } from "react"; import TimeAgo from "react-timeago"; import { ThemeToggle } from "../components/ThemeToggle"; +import { type Status, type StatusResponse, client } from "../lib/api"; import "../App.css"; const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000; @@ -190,20 +190,29 @@ function App() { const shouldShowLastFetch = hasResponse || hasError || hasTimeout; useEffect(() => { - let timeoutId: NodeJS.Timeout; + let timeoutId: NodeJS.Timeout | null = null; + let requestTimeoutId: NodeJS.Timeout | null = null; const fetchData = async () => { try { const startTime = Date.now(); - // Create a timeout promise + // Create a timeout promise with cleanup tracking const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Request timeout")), REQUEST_TIMEOUT); + requestTimeoutId = setTimeout(() => { + reject(new Error("Request timeout")); + }, REQUEST_TIMEOUT); }); // Race between the API call and timeout const statusData = await Promise.race([client.getStatus(), timeoutPromise]); + // Clear the timeout if the request succeeded + if (requestTimeoutId) { + clearTimeout(requestTimeoutId); + requestTimeoutId = null; + } + const endTime = Date.now(); const responseTime = endTime - startTime; @@ -214,9 +223,14 @@ function App() { lastFetch: new Date(), }); } catch (err) { + // Clear the timeout on error as well + if (requestTimeoutId) { + clearTimeout(requestTimeoutId); + requestTimeoutId = null; + } + const errorMessage = err instanceof Error ? err.message : "Failed to fetch data"; - // Check if it's a timeout error if (errorMessage === "Request timeout") { setState({ mode: "timeout", @@ -241,6 +255,9 @@ function App() { if (timeoutId) { clearTimeout(timeoutId); } + if (requestTimeoutId) { + clearTimeout(requestTimeoutId); + } }; }, []);