refactor: extract theme toggle styles to CSS and improve timeout handling

This commit is contained in:
2026-01-28 19:47:24 -06:00
parent 7cc8267c2e
commit fa2fc45aa9
12 changed files with 88 additions and 126 deletions
Generated
-15
View File
@@ -238,7 +238,6 @@ dependencies = [
"http 1.3.1", "http 1.3.1",
"mime_guess", "mime_guess",
"num-format", "num-format",
"once_cell",
"poise", "poise",
"rand 0.9.2", "rand 0.9.2",
"rapidhash", "rapidhash",
@@ -1284,12 +1283,6 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -3544,20 +3537,12 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [ dependencies = [
"bitflags 2.9.4", "bitflags 2.9.4",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util",
"http-range-header",
"httpdate",
"iri-string", "iri-string",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
+1 -2
View File
@@ -46,10 +46,9 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
url = "2.5" url = "2.5"
governor = "0.10.1" governor = "0.10.1"
once_cell = "1.21.3"
serde_path_to_error = "0.1.17" serde_path_to_error = "0.1.17"
num-format = "0.4.4" 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 } rust-embed = { version = "8.0", features = ["include-exclude"], optional = true }
mime_guess = { version = "2.0", optional = true } mime_guess = { version = "2.0", optional = true }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
+1 -1
View File
@@ -298,7 +298,7 @@ impl DateRange {
/// Get the number of weeks between start and end dates /// Get the number of weeks between start and end dates
pub fn weeks_duration(&self) -> u32 { pub fn weeks_duration(&self) -> u32 {
let duration = self.end.signed_duration_since(self.start); 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 /// Check if a specific date falls within this range
+2 -1
View File
@@ -168,8 +168,9 @@ impl SearchQuery {
} }
/// Sets the maximum number of results to return /// 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 { pub fn max_results(mut self, max_results: i32) -> Self {
self.max_results = max_results; self.max_results = max_results.clamp(1, 500);
self self
} }
+15 -17
View File
@@ -7,13 +7,12 @@ use cookie::Cookie;
use dashmap::DashMap; use dashmap::DashMap;
use governor::state::InMemoryState; use governor::state::InMemoryState;
use governor::{Quota, RateLimiter}; use governor::{Quota, RateLimiter};
use once_cell::sync::Lazy;
use rand::distr::{Alphanumeric, SampleString}; use rand::distr::{Alphanumeric, SampleString};
use reqwest_middleware::ClientWithMiddleware; use reqwest_middleware::ClientWithMiddleware;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::sync::Arc; use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::{Mutex, Notify}; use tokio::sync::{Mutex, Notify};
use tracing::{debug, info, trace}; 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, // 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. // preventing us from overwhelming the server with session creation requests.
static SESSION_CREATION_RATE_LIMITER: Lazy< static SESSION_CREATION_RATE_LIMITER: LazyLock<
RateLimiter<governor::state::direct::NotKeyed, InMemoryState, governor::clock::DefaultClock>, RateLimiter<governor::state::direct::NotKeyed, InMemoryState, governor::clock::DefaultClock>,
> = 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. /// Represents an active anonymous session within the Banner API.
/// Identified by multiple persistent cookies, as well as a client-generated "unique session ID". /// Identified by multiple persistent cookies, as well as a client-generated "unique session ID".
@@ -63,16 +62,16 @@ pub fn nonce() -> String {
impl BannerSession { impl BannerSession {
/// Creates a new session /// Creates a new session
pub fn new(unique_session_id: &str, jsessionid: &str, ssb_cookie: &str) -> Result<Self> { pub fn new(unique_session_id: &str, jsessionid: &str, ssb_cookie: &str) -> Self {
let now = Instant::now(); let now = Instant::now();
Ok(Self { Self {
created_at: now, created_at: now,
last_activity: None, last_activity: None,
unique_session_id: unique_session_id.to_string(), unique_session_id: unique_session_id.to_string(),
jsessionid: jsessionid.to_string(), jsessionid: jsessionid.to_string(),
ssb_cookie: ssb_cookie.to_string(), ssb_cookie: ssb_cookie.to_string(),
}) }
} }
/// Returns the unique session ID /// Returns the unique session ID
@@ -124,47 +123,46 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_new_session_returns_ok() { fn test_new_session_creates_session() {
let session = BannerSession::new("sess-1", "JSID123", "SSB456"); let session = BannerSession::new("sess-1", "JSID123", "SSB456");
assert!(session.is_ok()); assert_eq!(session.id(), "sess-1");
assert_eq!(session.unwrap().id(), "sess-1");
} }
#[test] #[test]
fn test_fresh_session_not_expired() { 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()); assert!(!session.is_expired());
} }
#[test] #[test]
fn test_fresh_session_not_been_used() { 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()); assert!(!session.been_used());
} }
#[test] #[test]
fn test_touch_marks_used() { 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(); session.touch();
assert!(session.been_used()); assert!(session.been_used());
} }
#[test] #[test]
fn test_touched_session_not_expired() { 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(); session.touch();
assert!(!session.is_expired()); assert!(!session.is_expired());
} }
#[test] #[test]
fn test_cookie_format() { 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"); assert_eq!(session.cookie(), "JSESSIONID=JSID123; SSB_COOKIE=SSB456");
} }
#[test] #[test]
fn test_id_returns_unique_session_id() { 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"); assert_eq!(session.id(), "my-unique-id");
} }
@@ -454,7 +452,7 @@ impl SessionPool {
self.select_term(&term.to_string(), &unique_session_id, &cookie_header) self.select_term(&term.to_string(), &unique_session_id, &cookie_header)
.await?; .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. /// Retrieves a list of terms from the Banner API.
+1 -1
View File
@@ -2,5 +2,5 @@
/// Returns a browser-like user agent string. /// Returns a browser-like user agent string.
pub fn user_agent() -> &'static str { 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"
} }
+10 -44
View File
@@ -5,58 +5,24 @@ use crate::data::models::TargetType;
use crate::error::Result; use crate::error::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::fmt; use thiserror::Error;
/// Errors that can occur during job parsing /// Errors that can occur during job parsing
#[derive(Debug)] #[derive(Debug, Error)]
pub enum JobParseError { 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), 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 /// Errors that can occur during job processing
#[derive(Debug)] #[derive(Debug, Error)]
pub enum JobError { pub enum JobError {
Recoverable(anyhow::Error), // API failures, network issues #[error("Recoverable error: {0}")]
Unrecoverable(anyhow::Error), // Parse errors, corrupted data Recoverable(#[source] anyhow::Error),
} #[error("Unrecoverable error: {0}")]
Unrecoverable(#[source] anyhow::Error),
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(),
}
}
} }
/// Common trait interface for all job types /// Common trait interface for all job types
+2 -2
View File
@@ -4,10 +4,10 @@
//! at compile time using rust-embed. //! at compile time using rust-embed.
use dashmap::DashMap; use dashmap::DashMap;
use once_cell::sync::Lazy;
use rapidhash::v3::rapidhash_v3; use rapidhash::v3::rapidhash_v3;
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use std::fmt; use std::fmt;
use std::sync::LazyLock;
/// Embedded web assets from the dist directory /// Embedded web assets from the dist directory
#[derive(RustEmbed)] #[derive(RustEmbed)]
@@ -65,7 +65,7 @@ impl AssetMetadata {
} }
/// Global cache for asset metadata to avoid repeated calculations /// Global cache for asset metadata to avoid repeated calculations
static ASSET_CACHE: Lazy<DashMap<String, AssetMetadata>> = Lazy::new(DashMap::new); static ASSET_CACHE: LazyLock<DashMap<String, AssetMetadata>> = LazyLock::new(DashMap::new);
/// Get cached asset metadata for a file path, caching on-demand /// Get cached asset metadata for a file path, caching on-demand
/// Returns AssetMetadata containing MIME type and RapidHash hash /// Returns AssetMetadata containing MIME type and RapidHash hash
+20
View File
@@ -20,6 +20,26 @@
animation: pulse 2s ease-in-out infinite; 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 */ /* Screen reader only text */
.sr-only { .sr-only {
position: absolute; position: absolute;
+3 -27
View File
@@ -1,6 +1,6 @@
import { useTheme } from "next-themes";
import { Button } from "@radix-ui/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"; import { useMemo } from "react";
export function ThemeToggle() { export function ThemeToggle() {
@@ -28,31 +28,7 @@ export function ThemeToggle() {
}, [nextTheme]); }, [nextTheme]);
return ( return (
<Button <Button variant="ghost" size="3" onClick={() => setTheme(nextTheme)} className="theme-toggle">
variant="ghost"
size="3"
onClick={() => setTheme(nextTheme)}
style={{
cursor: "pointer",
backgroundColor: "transparent",
border: "none",
margin: "4px",
padding: "7px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--gray-11)",
transition: "background-color 0.2s, color 0.2s",
transform: "scale(1.25)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "var(--gray-4)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{icon} {icon}
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
+4 -4
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { BannerApiClient } from "./api"; import { BannerApiClient } from "./api";
// Mock fetch // Mock fetch
@@ -31,12 +31,12 @@ describe("BannerApiClient", () => {
it("should fetch status data", async () => { it("should fetch status data", async () => {
const mockStatus = { const mockStatus = {
status: "active", status: "active" as const,
version: "0.3.4", version: "0.3.4",
commit: "abc1234", commit: "abc1234",
services: { services: {
web: { name: "web", status: "active" }, web: { name: "web", status: "active" as const },
database: { name: "database", status: "connected" }, database: { name: "database", status: "connected" as const },
}, },
}; };
+29 -12
View File
@@ -1,21 +1,21 @@
import { Card, Flex, Skeleton, Text, Tooltip } from "@radix-ui/themes";
import { createFileRoute } from "@tanstack/react-router"; 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 { import {
CheckCircle, Activity,
XCircle,
Clock,
Bot, Bot,
CheckCircle,
Circle,
Clock,
Globe, Globe,
Hourglass, Hourglass,
Activity,
MessageCircle, MessageCircle,
Circle,
WifiOff, WifiOff,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react";
import TimeAgo from "react-timeago"; import TimeAgo from "react-timeago";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { type Status, type StatusResponse, client } from "../lib/api";
import "../App.css"; import "../App.css";
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000; const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
@@ -190,20 +190,29 @@ function App() {
const shouldShowLastFetch = hasResponse || hasError || hasTimeout; const shouldShowLastFetch = hasResponse || hasError || hasTimeout;
useEffect(() => { useEffect(() => {
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout | null = null;
let requestTimeoutId: NodeJS.Timeout | null = null;
const fetchData = async () => { const fetchData = async () => {
try { try {
const startTime = Date.now(); const startTime = Date.now();
// Create a timeout promise // Create a timeout promise with cleanup tracking
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, 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 // Race between the API call and timeout
const statusData = await Promise.race([client.getStatus(), timeoutPromise]); 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 endTime = Date.now();
const responseTime = endTime - startTime; const responseTime = endTime - startTime;
@@ -214,9 +223,14 @@ function App() {
lastFetch: new Date(), lastFetch: new Date(),
}); });
} catch (err) { } 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"; const errorMessage = err instanceof Error ? err.message : "Failed to fetch data";
// Check if it's a timeout error
if (errorMessage === "Request timeout") { if (errorMessage === "Request timeout") {
setState({ setState({
mode: "timeout", mode: "timeout",
@@ -241,6 +255,9 @@ function App() {
if (timeoutId) { if (timeoutId) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
if (requestTimeoutId) {
clearTimeout(requestTimeoutId);
}
}; };
}, []); }, []);