feat: implement real-time service status tracking and health reporting

This commit is contained in:
2026-01-28 18:37:42 -06:00
parent 1733ee5f86
commit 7cc8267c2e
22 changed files with 308 additions and 284 deletions
Generated
-7
View File
@@ -253,7 +253,6 @@ dependencies = [
"sqlx", "sqlx",
"thiserror 2.0.16", "thiserror 2.0.16",
"time", "time",
"tl",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tower-http", "tower-http",
@@ -3368,12 +3367,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tl"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b130bd8a58c163224b44e217b4239ca7b927d82bf6cc2fea1fc561d15056e3f7"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.47.1" version = "1.47.1"
-1
View File
@@ -42,7 +42,6 @@ thiserror = "2.0.16"
time = "0.3.43" time = "0.3.43"
tokio = { version = "1.47.1", features = ["full"] } tokio = { version = "1.47.1", features = ["full"] }
tokio-util = "0.7" tokio-util = "0.7"
tl = "0.7.8"
tracing = "0.1.41" 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"
+3
View File
@@ -195,3 +195,6 @@ test-smoke port="18080":
alias b := bun alias b := bun
bun *ARGS: bun *ARGS:
cd web && bun {{ ARGS }} cd web && bun {{ ARGS }}
sql *ARGS:
lazysql ${DATABASE_URL}
+9 -6
View File
@@ -12,6 +12,7 @@ use sqlx::postgres::PgPoolOptions;
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::Context;
use tracing::{error, info}; use tracing::{error, info};
/// Main application struct containing all necessary components /// Main application struct containing all necessary components
@@ -36,7 +37,7 @@ impl App {
} }
})) }))
.extract() .extract()
.expect("Failed to load config"); .context("Failed to load config")?;
// Check if the database URL is via private networking // Check if the database URL is via private networking
let is_private = config.database_url.contains("railway.internal"); let is_private = config.database_url.contains("railway.internal");
@@ -52,7 +53,7 @@ impl App {
.max_lifetime(Duration::from_secs(60 * 30)) .max_lifetime(Duration::from_secs(60 * 30))
.connect(&config.database_url) .connect(&config.database_url)
.await .await
.expect("Failed to create database pool"); .context("Failed to create database pool")?;
info!( info!(
is_private = is_private, is_private = is_private,
@@ -65,7 +66,7 @@ impl App {
sqlx::migrate!("./migrations") sqlx::migrate!("./migrations")
.run(&db_pool) .run(&db_pool)
.await .await
.expect("Failed to run database migrations"); .context("Failed to run database migrations")?;
info!("Database migrations completed successfully"); info!("Database migrations completed successfully");
// Create BannerApi and AppState // Create BannerApi and AppState
@@ -73,7 +74,7 @@ impl App {
config.banner_base_url.clone(), config.banner_base_url.clone(),
config.rate_limiting.clone(), config.rate_limiting.clone(),
) )
.expect("Failed to create BannerApi"); .context("Failed to create BannerApi")?;
let banner_api_arc = Arc::new(banner_api); let banner_api_arc = Arc::new(banner_api);
let app_state = AppState::new(banner_api_arc.clone(), db_pool.clone()); let app_state = AppState::new(banner_api_arc.clone(), db_pool.clone());
@@ -91,7 +92,7 @@ impl App {
pub fn setup_services(&mut self, services: &[ServiceName]) -> Result<(), anyhow::Error> { pub fn setup_services(&mut self, services: &[ServiceName]) -> Result<(), anyhow::Error> {
// Register enabled services with the manager // Register enabled services with the manager
if services.contains(&ServiceName::Web) { if services.contains(&ServiceName::Web) {
let web_service = Box::new(WebService::new(self.config.port)); let web_service = Box::new(WebService::new(self.config.port, self.app_state.clone()));
self.service_manager self.service_manager
.register_service(ServiceName::Web.as_str(), web_service); .register_service(ServiceName::Web.as_str(), web_service);
} }
@@ -100,6 +101,7 @@ impl App {
let scraper_service = Box::new(ScraperService::new( let scraper_service = Box::new(ScraperService::new(
self.db_pool.clone(), self.db_pool.clone(),
self.banner_api.clone(), self.banner_api.clone(),
self.app_state.service_statuses.clone(),
)); ));
self.service_manager self.service_manager
.register_service(ServiceName::Scraper.as_str(), scraper_service); .register_service(ServiceName::Scraper.as_str(), scraper_service);
@@ -130,12 +132,13 @@ impl App {
status_shutdown_rx, status_shutdown_rx,
) )
.await .await
.expect("Failed to create Discord client"); .context("Failed to create Discord client")?;
let bot_service = Box::new(BotService::new( let bot_service = Box::new(BotService::new(
client, client,
status_task_handle, status_task_handle,
status_shutdown_tx, status_shutdown_tx,
self.app_state.service_statuses.clone(),
)); ));
self.service_manager self.service_manager
+1 -25
View File
@@ -21,9 +21,9 @@ pub struct BannerApi {
base_url: String, base_url: String,
} }
#[allow(dead_code)]
impl BannerApi { impl BannerApi {
/// Creates a new Banner API client. /// Creates a new Banner API client.
#[allow(dead_code)]
pub fn new(base_url: String) -> Result<Self> { pub fn new(base_url: String) -> Result<Self> {
Self::new_with_config(base_url, RateLimitingConfig::default()) Self::new_with_config(base_url, RateLimitingConfig::default())
} }
@@ -231,30 +231,6 @@ impl BannerApi {
.await .await
} }
/// Retrieves a list of instructors from the Banner API.
pub async fn get_instructors(
&self,
search: &str,
term: &str,
offset: i32,
max_results: i32,
) -> Result<Vec<Instructor>> {
self.get_list_endpoint("get_instructor", search, term, offset, max_results)
.await
}
/// Retrieves a list of campuses from the Banner API.
pub async fn get_campuses(
&self,
search: &str,
term: &str,
offset: i32,
max_results: i32,
) -> Result<Vec<Pair>> {
self.get_list_endpoint("get_campus", search, term, offset, max_results)
.await
}
/// Retrieves meeting time information for a course. /// Retrieves meeting time information for a course.
pub async fn get_course_meeting_time( pub async fn get_course_meeting_time(
&self, &self,
+1
View File
@@ -11,6 +11,7 @@ pub struct Pair {
pub type BannerTerm = Pair; pub type BannerTerm = Pair;
/// Represents an instructor in the Banner system /// Represents an instructor in the Banner system
#[allow(dead_code)]
pub type Instructor = Pair; pub type Instructor = Pair;
impl BannerTerm { impl BannerTerm {
+2 -22
View File
@@ -1,8 +1,8 @@
use bitflags::{Flags, bitflags}; use bitflags::{bitflags, Flags};
use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday}; use chrono::{DateTime, NaiveDate, NaiveTime, Timelike, Utc, Weekday};
use extension_traits::extension; use extension_traits::extension;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use std::{cmp::Ordering, fmt::Display, str::FromStr}; use std::{cmp::Ordering, str::FromStr};
use super::terms::Term; use super::terms::Term;
@@ -394,26 +394,6 @@ impl MeetingLocation {
} }
} }
impl Display for MeetingLocation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MeetingLocation::Online => write!(f, "Online"),
MeetingLocation::InPerson {
campus,
building,
building_description,
room,
..
} => write!(
f,
"{campus} | {building_name} | {building_code} {room}",
building_name = building_description,
building_code = building,
),
}
}
}
/// Clean, parsed meeting schedule information /// Clean, parsed meeting schedule information
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeetingScheduleInfo { pub struct MeetingScheduleInfo {
+27 -17
View File
@@ -32,7 +32,6 @@ pub struct SearchQuery {
course_number_range: Option<Range>, course_number_range: Option<Range>,
} }
#[allow(dead_code)]
impl SearchQuery { impl SearchQuery {
/// Creates a new SearchQuery with default values /// Creates a new SearchQuery with default values
pub fn new() -> Self { pub fn new() -> Self {
@@ -68,6 +67,7 @@ impl SearchQuery {
} }
/// Adds a keyword to the query /// Adds a keyword to the query
#[allow(dead_code)]
pub fn keyword<S: Into<String>>(mut self, keyword: S) -> Self { pub fn keyword<S: Into<String>>(mut self, keyword: S) -> Self {
match &mut self.keywords { match &mut self.keywords {
Some(keywords) => keywords.push(keyword.into()), Some(keywords) => keywords.push(keyword.into()),
@@ -77,54 +77,63 @@ impl SearchQuery {
} }
/// Sets whether to search for open courses only /// Sets whether to search for open courses only
#[allow(dead_code)]
pub fn open_only(mut self, open_only: bool) -> Self { pub fn open_only(mut self, open_only: bool) -> Self {
self.open_only = Some(open_only); self.open_only = Some(open_only);
self self
} }
/// Sets the term part for the query /// Sets the term part for the query
#[allow(dead_code)]
pub fn term_part(mut self, term_part: Vec<String>) -> Self { pub fn term_part(mut self, term_part: Vec<String>) -> Self {
self.term_part = Some(term_part); self.term_part = Some(term_part);
self self
} }
/// Sets the campuses for the query /// Sets the campuses for the query
#[allow(dead_code)]
pub fn campus(mut self, campus: Vec<String>) -> Self { pub fn campus(mut self, campus: Vec<String>) -> Self {
self.campus = Some(campus); self.campus = Some(campus);
self self
} }
/// Sets the instructional methods for the query /// Sets the instructional methods for the query
#[allow(dead_code)]
pub fn instructional_method(mut self, instructional_method: Vec<String>) -> Self { pub fn instructional_method(mut self, instructional_method: Vec<String>) -> Self {
self.instructional_method = Some(instructional_method); self.instructional_method = Some(instructional_method);
self self
} }
/// Sets the attributes for the query /// Sets the attributes for the query
#[allow(dead_code)]
pub fn attributes(mut self, attributes: Vec<String>) -> Self { pub fn attributes(mut self, attributes: Vec<String>) -> Self {
self.attributes = Some(attributes); self.attributes = Some(attributes);
self self
} }
/// Sets the instructors for the query /// Sets the instructors for the query
#[allow(dead_code)]
pub fn instructor(mut self, instructor: Vec<u64>) -> Self { pub fn instructor(mut self, instructor: Vec<u64>) -> Self {
self.instructor = Some(instructor); self.instructor = Some(instructor);
self self
} }
/// Sets the start time for the query /// Sets the start time for the query
#[allow(dead_code)]
pub fn start_time(mut self, start_time: Duration) -> Self { pub fn start_time(mut self, start_time: Duration) -> Self {
self.start_time = Some(start_time); self.start_time = Some(start_time);
self self
} }
/// Sets the end time for the query /// Sets the end time for the query
#[allow(dead_code)]
pub fn end_time(mut self, end_time: Duration) -> Self { pub fn end_time(mut self, end_time: Duration) -> Self {
self.end_time = Some(end_time); self.end_time = Some(end_time);
self self
} }
/// Sets the credit range for the query /// Sets the credit range for the query
#[allow(dead_code)]
pub fn credits(mut self, low: i32, high: i32) -> Self { pub fn credits(mut self, low: i32, high: i32) -> Self {
self.min_credits = Some(low); self.min_credits = Some(low);
self.max_credits = Some(high); self.max_credits = Some(high);
@@ -132,12 +141,14 @@ impl SearchQuery {
} }
/// Sets the minimum credits for the query /// Sets the minimum credits for the query
#[allow(dead_code)]
pub fn min_credits(mut self, value: i32) -> Self { pub fn min_credits(mut self, value: i32) -> Self {
self.min_credits = Some(value); self.min_credits = Some(value);
self self
} }
/// Sets the maximum credits for the query /// Sets the maximum credits for the query
#[allow(dead_code)]
pub fn max_credits(mut self, value: i32) -> Self { pub fn max_credits(mut self, value: i32) -> Self {
self.max_credits = Some(value); self.max_credits = Some(value);
self self
@@ -150,6 +161,7 @@ impl SearchQuery {
} }
/// Sets the offset for pagination /// Sets the offset for pagination
#[allow(dead_code)]
pub fn offset(mut self, offset: i32) -> Self { pub fn offset(mut self, offset: i32) -> Self {
self.offset = offset; self.offset = offset;
self self
@@ -253,27 +265,25 @@ impl SearchQuery {
} }
} }
/// Formats a Duration into hour, minute, and meridiem strings for Banner API /// Formats a Duration into hour, minute, and meridiem strings for Banner API.
///
/// Uses 12-hour format: midnight = 12:00 AM, noon = 12:00 PM.
fn format_time_parameter(duration: Duration) -> (String, String, String) { fn format_time_parameter(duration: Duration) -> (String, String, String) {
let total_minutes = duration.as_secs() / 60; let total_minutes = duration.as_secs() / 60;
let hours = total_minutes / 60; let hours = total_minutes / 60;
let minutes = total_minutes % 60; let minutes = total_minutes % 60;
let minute_str = minutes.to_string(); let meridiem = if hours >= 12 { "PM" } else { "AM" };
let hour_12 = match hours % 12 {
0 => 12,
h => h,
};
if hours >= 12 { (
let meridiem = "PM".to_string(); hour_12.to_string(),
let hour_str = if hours >= 13 { minutes.to_string(),
(hours - 12).to_string() meridiem.to_string(),
} else { )
hours.to_string()
};
(hour_str, minute_str, meridiem)
} else {
let meridiem = "AM".to_string();
let hour_str = hours.to_string();
(hour_str, minute_str, meridiem)
}
} }
#[cfg(test)] #[cfg(test)]
@@ -394,7 +404,7 @@ mod tests {
#[test] #[test]
fn test_format_time_midnight() { fn test_format_time_midnight() {
let (h, m, mer) = format_time_parameter(Duration::from_secs(0)); let (h, m, mer) = format_time_parameter(Duration::from_secs(0));
assert_eq!(h, "0"); assert_eq!(h, "12");
assert_eq!(m, "0"); assert_eq!(m, "0");
assert_eq!(mer, "AM"); assert_eq!(mer, "AM");
} }
+82 -127
View File
@@ -2,116 +2,78 @@
use crate::banner::{Course, MeetingDays, MeetingScheduleInfo, WeekdayExt}; use crate::banner::{Course, MeetingDays, MeetingScheduleInfo, WeekdayExt};
use crate::bot::{Context, Error, utils}; use crate::bot::{Context, Error, utils};
use chrono::{Datelike, NaiveDate, Utc}; use chrono::{Datelike, Duration, NaiveDate, Utc, Weekday};
use serenity::all::CreateAttachment; use serenity::all::CreateAttachment;
use tracing::info; use tracing::info;
/// Represents a holiday or special day that should be excluded from class schedules /// Find the nth occurrence of a weekday in a given month/year (1-based).
#[derive(Debug, Clone)] fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> Option<NaiveDate> {
enum Holiday { let first = NaiveDate::from_ymd_opt(year, month, 1)?;
/// A single-day holiday let days_ahead = (weekday.num_days_from_monday() as i64
Single { month: u32, day: u32 }, - first.weekday().num_days_from_monday() as i64)
/// A multi-day holiday range .rem_euclid(7) as u32;
Range { let day = 1 + days_ahead + 7 * (n - 1);
month: u32, NaiveDate::from_ymd_opt(year, month, day)
start_day: u32,
end_day: u32,
},
} }
impl Holiday { /// Compute a consecutive range of dates starting from `start` for `count` days.
/// Check if a specific date falls within this holiday fn date_range(start: NaiveDate, count: i64) -> Vec<NaiveDate> {
fn contains_date(&self, date: NaiveDate) -> bool { (0..count).filter_map(|i| start.checked_add_signed(Duration::days(i))).collect()
match self {
Holiday::Single { month, day, .. } => date.month() == *month && date.day() == *day,
Holiday::Range {
month,
start_day,
end_day,
..
} => date.month() == *month && date.day() >= *start_day && date.day() <= *end_day,
}
}
/// Get all dates in this holiday for a given year
fn get_dates_for_year(&self, year: i32) -> Vec<NaiveDate> {
match self {
Holiday::Single { month, day, .. } => {
if let Some(date) = NaiveDate::from_ymd_opt(year, *month, *day) {
vec![date]
} else {
Vec::new()
}
}
Holiday::Range {
month,
start_day,
end_day,
..
} => {
let mut dates = Vec::new();
for day in *start_day..=*end_day {
if let Some(date) = NaiveDate::from_ymd_opt(year, *month, day) {
dates.push(date);
}
}
dates
}
}
}
} }
/// University holidays excluded from class schedules. /// Compute university holidays for a given year.
/// ///
/// WARNING: These dates are specific to the UTSA 2024-2025 academic calendar and must be /// Federal holidays use weekday-of-month rules so they're correct for any year.
/// updated each academic year. Many of these holidays fall on different dates annually /// University-specific breaks (Fall Break, Spring Break, Winter Holiday) are derived
/// (e.g., Labor Day is the first Monday of September, Thanksgiving is the fourth Thursday /// from anchoring federal holidays or using UTSA's typical scheduling patterns.
/// of November). Ideally these would be loaded from a configuration file or computed fn compute_holidays_for_year(year: i32) -> Vec<(&'static str, Vec<NaiveDate>)> {
/// dynamically from federal/university calendar rules. let mut holidays = Vec::new();
// TODO: Load holiday dates from configuration or compute dynamically per academic year.
const UNIVERSITY_HOLIDAYS: &[(&str, Holiday)] = &[ // Labor Day: 1st Monday of September
("Labor Day", Holiday::Single { month: 9, day: 1 }), if let Some(d) = nth_weekday_of_month(year, 9, Weekday::Mon, 1) {
( holidays.push(("Labor Day", vec![d]));
"Fall Break", }
Holiday::Range {
month: 10, // Fall Break: Mon-Tue of Columbus Day week (2nd Monday of October + Tuesday)
start_day: 13, if let Some(mon) = nth_weekday_of_month(year, 10, Weekday::Mon, 2) {
end_day: 14, holidays.push(("Fall Break", date_range(mon, 2)));
}, }
),
( // Day before Thanksgiving: Wednesday before 4th Thursday of November
"Unspecified Holiday", if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4)
Holiday::Single { month: 11, day: 26 }, && let Some(wed) = thu.checked_sub_signed(Duration::days(1))
), {
( holidays.push(("Day Before Thanksgiving", vec![wed]));
"Thanksgiving", }
Holiday::Range {
month: 11, // Thanksgiving: 4th Thursday of November + Friday
start_day: 28, if let Some(thu) = nth_weekday_of_month(year, 11, Weekday::Thu, 4) {
end_day: 29, holidays.push(("Thanksgiving", date_range(thu, 2)));
}, }
),
("Student Study Day", Holiday::Single { month: 12, day: 5 }), // Winter Holiday: Dec 23-31
( if let Some(start) = NaiveDate::from_ymd_opt(year, 12, 23) {
"Winter Holiday", holidays.push(("Winter Holiday", date_range(start, 9)));
Holiday::Range { }
month: 12,
start_day: 23, // New Year's Day: January 1
end_day: 31, if let Some(d) = NaiveDate::from_ymd_opt(year, 1, 1) {
}, holidays.push(("New Year's Day", vec![d]));
), }
("New Year's Day", Holiday::Single { month: 1, day: 1 }),
("MLK Day", Holiday::Single { month: 1, day: 20 }), // MLK Day: 3rd Monday of January
( if let Some(d) = nth_weekday_of_month(year, 1, Weekday::Mon, 3) {
"Spring Break", holidays.push(("MLK Day", vec![d]));
Holiday::Range { }
month: 3,
start_day: 10, // Spring Break: full week (Mon-Sat) starting the 2nd or 3rd Monday of March
end_day: 15, // UTSA typically uses the 2nd full week of March
}, if let Some(mon) = nth_weekday_of_month(year, 3, Weekday::Mon, 2) {
), holidays.push(("Spring Break", date_range(mon, 6)));
("Student Study Day", Holiday::Single { month: 5, day: 9 }), }
];
holidays
}
/// Generate an ICS file for a course /// Generate an ICS file for a course
#[poise::command(slash_command, prefix_command)] #[poise::command(slash_command, prefix_command)]
@@ -329,10 +291,16 @@ fn generate_event_content(
} }
// Collect holiday names for reporting // Collect holiday names for reporting
let start_year = meeting_time.date_range.start.year();
let end_year = meeting_time.date_range.end.year();
let all_holidays: Vec<_> = (start_year..=end_year)
.flat_map(compute_holidays_for_year)
.collect();
let mut holiday_names = Vec::new(); let mut holiday_names = Vec::new();
for (holiday_name, holiday) in UNIVERSITY_HOLIDAYS { for (holiday_name, holiday_dates) in &all_holidays {
for &exception_date in &holiday_exceptions { for &exception_date in &holiday_exceptions {
if holiday.contains_date(exception_date) { if holiday_dates.contains(&exception_date) {
holiday_names.push(format!( holiday_names.push(format!(
"{} ({})", "{} ({})",
holiday_name, holiday_name,
@@ -344,6 +312,7 @@ fn generate_event_content(
holiday_names.sort(); holiday_names.sort();
holiday_names.dedup(); holiday_names.dedup();
event_content.push_str("END:VEVENT\r\n");
return Ok((event_content, holiday_names)); return Ok((event_content, holiday_names));
} }
} }
@@ -362,32 +331,18 @@ fn class_meets_on_date(meeting_time: &MeetingScheduleInfo, date: NaiveDate) -> b
/// Get holiday dates that fall within the course date range and would conflict with class meetings /// Get holiday dates that fall within the course date range and would conflict with class meetings
fn get_holiday_exceptions(meeting_time: &MeetingScheduleInfo) -> Vec<NaiveDate> { fn get_holiday_exceptions(meeting_time: &MeetingScheduleInfo) -> Vec<NaiveDate> {
let mut exceptions = Vec::new();
// Get the year range from the course date range
let start_year = meeting_time.date_range.start.year(); let start_year = meeting_time.date_range.start.year();
let end_year = meeting_time.date_range.end.year(); let end_year = meeting_time.date_range.end.year();
for (_, holiday) in UNIVERSITY_HOLIDAYS { (start_year..=end_year)
// Check for the holiday in each year of the course .flat_map(compute_holidays_for_year)
for year in start_year..=end_year { .flat_map(|(_, dates)| dates)
let holiday_dates = holiday.get_dates_for_year(year); .filter(|&date| {
date >= meeting_time.date_range.start
for holiday_date in holiday_dates { && date <= meeting_time.date_range.end
// Check if the holiday falls within the course date range && class_meets_on_date(meeting_time, date)
if holiday_date >= meeting_time.date_range.start })
&& holiday_date <= meeting_time.date_range.end .collect()
{
// Check if the class would actually meet on this day
if class_meets_on_date(meeting_time, holiday_date) {
exceptions.push(holiday_date);
}
}
}
}
}
exceptions
} }
/// Generate EXDATE property for holiday exceptions /// Generate EXDATE property for holiday exceptions
+2 -2
View File
@@ -24,8 +24,8 @@ pub async fn search(
// Defer the response since this might take a while // Defer the response since this might take a while
ctx.defer().await?; ctx.defer().await?;
// Build the search query // Build the search query — no default credit filter so all courses are visible
let mut query = SearchQuery::new().credits(3, 6); let mut query = SearchQuery::new();
if let Some(title) = title { if let Some(title) = title {
query = query.title(title); query = query.title(title);
+1 -1
View File
@@ -90,7 +90,7 @@ pub async fn unlock_and_increment_retry(
"UPDATE scrape_jobs "UPDATE scrape_jobs
SET locked_at = NULL, retry_count = retry_count + 1 SET locked_at = NULL, retry_count = retry_count + 1
WHERE id = $1 WHERE id = $1
RETURNING CASE WHEN retry_count + 1 < $2 THEN retry_count + 1 ELSE NULL END", RETURNING CASE WHEN retry_count < $2 THEN retry_count ELSE NULL END",
) )
.bind(job_id) .bind(job_id)
.bind(max_retries) .bind(max_retries)
+1
View File
@@ -11,5 +11,6 @@ pub mod scraper;
pub mod services; pub mod services;
pub mod signals; pub mod signals;
pub mod state; pub mod state;
pub mod status;
pub mod utils; pub mod utils;
pub mod web; pub mod web;
+8 -6
View File
@@ -18,6 +18,8 @@ mod scraper;
mod services; mod services;
mod signals; mod signals;
mod state; mod state;
#[allow(dead_code)]
mod status;
mod web; mod web;
#[tokio::main] #[tokio::main]
@@ -31,17 +33,17 @@ async fn main() -> ExitCode {
let enabled_services: Vec<ServiceName> = let enabled_services: Vec<ServiceName> =
determine_enabled_services(&args).expect("Failed to determine enabled services"); determine_enabled_services(&args).expect("Failed to determine enabled services");
// Create and initialize the application
let mut app = App::new().await.expect("Failed to initialize application");
// Setup logging — must happen before any info!() calls to avoid silently dropped logs
setup_logging(app.config(), args.tracing);
info!( info!(
enabled_services = ?enabled_services, enabled_services = ?enabled_services,
"services configuration loaded" "services configuration loaded"
); );
// Create and initialize the application
let mut app = App::new().await.expect("Failed to initialize application");
// Setup logging
setup_logging(app.config(), args.tracing);
// Log application startup context // Log application startup context
info!( info!(
version = env!("CARGO_PKG_VERSION"), version = env!("CARGO_PKG_VERSION"),
+6 -1
View File
@@ -4,6 +4,7 @@ pub mod worker;
use crate::banner::BannerApi; use crate::banner::BannerApi;
use crate::services::Service; use crate::services::Service;
use crate::status::{ServiceStatus, ServiceStatusRegistry};
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -20,6 +21,7 @@ use self::worker::Worker;
pub struct ScraperService { pub struct ScraperService {
db_pool: PgPool, db_pool: PgPool,
banner_api: Arc<BannerApi>, banner_api: Arc<BannerApi>,
service_statuses: ServiceStatusRegistry,
scheduler_handle: Option<JoinHandle<()>>, scheduler_handle: Option<JoinHandle<()>>,
worker_handles: Vec<JoinHandle<()>>, worker_handles: Vec<JoinHandle<()>>,
shutdown_tx: Option<broadcast::Sender<()>>, shutdown_tx: Option<broadcast::Sender<()>>,
@@ -27,10 +29,11 @@ pub struct ScraperService {
impl ScraperService { impl ScraperService {
/// Creates a new `ScraperService`. /// Creates a new `ScraperService`.
pub fn new(db_pool: PgPool, banner_api: Arc<BannerApi>) -> Self { pub fn new(db_pool: PgPool, banner_api: Arc<BannerApi>, service_statuses: ServiceStatusRegistry) -> Self {
Self { Self {
db_pool, db_pool,
banner_api, banner_api,
service_statuses,
scheduler_handle: None, scheduler_handle: None,
worker_handles: Vec::new(), worker_handles: Vec::new(),
shutdown_tx: None, shutdown_tx: None,
@@ -66,6 +69,7 @@ impl ScraperService {
worker_count = self.worker_handles.len(), worker_count = self.worker_handles.len(),
"Spawned worker tasks" "Spawned worker tasks"
); );
self.service_statuses.set("scraper", ServiceStatus::Active);
} }
} }
@@ -82,6 +86,7 @@ impl Service for ScraperService {
} }
async fn shutdown(&mut self) -> Result<(), anyhow::Error> { async fn shutdown(&mut self) -> Result<(), anyhow::Error> {
self.service_statuses.set("scraper", ServiceStatus::Disabled);
info!("Shutting down scraper service"); info!("Shutting down scraper service");
// Send shutdown signal to all tasks // Send shutdown signal to all tasks
+7
View File
@@ -2,6 +2,7 @@ use super::Service;
use crate::bot::{Data, get_commands}; use crate::bot::{Data, get_commands};
use crate::config::Config; use crate::config::Config;
use crate::state::AppState; use crate::state::AppState;
use crate::status::{ServiceStatus, ServiceStatusRegistry};
use num_format::{Locale, ToFormattedString}; use num_format::{Locale, ToFormattedString};
use serenity::Client; use serenity::Client;
use serenity::all::{ActivityData, ClientBuilder, GatewayIntents}; use serenity::all::{ActivityData, ClientBuilder, GatewayIntents};
@@ -17,6 +18,7 @@ pub struct BotService {
shard_manager: Arc<serenity::gateway::ShardManager>, shard_manager: Arc<serenity::gateway::ShardManager>,
status_task_handle: Arc<Mutex<Option<JoinHandle<()>>>>, status_task_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
status_shutdown_tx: Option<broadcast::Sender<()>>, status_shutdown_tx: Option<broadcast::Sender<()>>,
service_statuses: ServiceStatusRegistry,
} }
impl BotService { impl BotService {
@@ -98,6 +100,8 @@ impl BotService {
); );
*status_task_handle.lock().await = Some(handle); *status_task_handle.lock().await = Some(handle);
app_state.service_statuses.set("bot", ServiceStatus::Active);
Ok(Data { app_state }) Ok(Data { app_state })
}) })
}) })
@@ -186,6 +190,7 @@ impl BotService {
client: Client, client: Client,
status_task_handle: Arc<Mutex<Option<JoinHandle<()>>>>, status_task_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
status_shutdown_tx: broadcast::Sender<()>, status_shutdown_tx: broadcast::Sender<()>,
service_statuses: ServiceStatusRegistry,
) -> Self { ) -> Self {
let shard_manager = client.shard_manager.clone(); let shard_manager = client.shard_manager.clone();
@@ -194,6 +199,7 @@ impl BotService {
shard_manager, shard_manager,
status_task_handle, status_task_handle,
status_shutdown_tx: Some(status_shutdown_tx), status_shutdown_tx: Some(status_shutdown_tx),
service_statuses,
} }
} }
} }
@@ -218,6 +224,7 @@ impl Service for BotService {
} }
async fn shutdown(&mut self) -> Result<(), anyhow::Error> { async fn shutdown(&mut self) -> Result<(), anyhow::Error> {
self.service_statuses.set("bot", ServiceStatus::Disabled);
// Signal status update task to stop // Signal status update task to stop
if let Some(status_shutdown_tx) = self.status_shutdown_tx.take() { if let Some(status_shutdown_tx) = self.status_shutdown_tx.take() {
let _ = status_shutdown_tx.send(()); let _ = status_shutdown_tx.send(());
+44 -3
View File
@@ -1,4 +1,6 @@
use super::Service; use super::Service;
use crate::state::AppState;
use crate::status::ServiceStatus;
use crate::web::create_router; use crate::web::create_router;
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
@@ -8,16 +10,47 @@ use tracing::{info, trace, warn};
/// Web server service implementation /// Web server service implementation
pub struct WebService { pub struct WebService {
port: u16, port: u16,
app_state: AppState,
shutdown_tx: Option<broadcast::Sender<()>>, shutdown_tx: Option<broadcast::Sender<()>>,
} }
impl WebService { impl WebService {
pub fn new(port: u16) -> Self { pub fn new(port: u16, app_state: AppState) -> Self {
Self { Self {
port, port,
app_state,
shutdown_tx: None, shutdown_tx: None,
} }
} }
/// Periodically pings the database and updates the "database" service status.
async fn db_health_check_loop(
state: AppState,
mut shutdown_rx: broadcast::Receiver<()>,
) {
use std::time::Duration;
let mut interval = tokio::time::interval(Duration::from_secs(30));
loop {
tokio::select! {
_ = interval.tick() => {
let status = match sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.db_pool)
.await
{
Ok(_) => ServiceStatus::Connected,
Err(e) => {
warn!(error = %e, "DB health check failed");
ServiceStatus::Error
}
};
state.service_statuses.set("database", status);
}
_ = shutdown_rx.recv() => {
break;
}
}
}
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -28,11 +61,12 @@ impl Service for WebService {
async fn run(&mut self) -> Result<(), anyhow::Error> { async fn run(&mut self) -> Result<(), anyhow::Error> {
// Create the main router with Banner API routes // Create the main router with Banner API routes
let app = create_router(); let app = create_router(self.app_state.clone());
let addr = SocketAddr::from(([0, 0, 0, 0], self.port)); let addr = SocketAddr::from(([0, 0, 0, 0], self.port));
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
self.app_state.service_statuses.set("web", ServiceStatus::Active);
info!( info!(
service = "web", service = "web",
address = %addr, address = %addr,
@@ -42,7 +76,14 @@ impl Service for WebService {
// Create internal shutdown channel for axum graceful shutdown // Create internal shutdown channel for axum graceful shutdown
let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1); let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1);
self.shutdown_tx = Some(shutdown_tx); self.shutdown_tx = Some(shutdown_tx.clone());
// Spawn background DB health check
let health_state = self.app_state.clone();
let health_shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move {
Self::db_health_check_loop(health_state, health_shutdown_rx).await;
});
// Use axum's graceful shutdown with the internal shutdown signal // Use axum's graceful shutdown with the internal shutdown signal
axum::serve(listener, app) axum::serve(listener, app)
+3
View File
@@ -2,6 +2,7 @@
use crate::banner::BannerApi; use crate::banner::BannerApi;
use crate::banner::Course; use crate::banner::Course;
use crate::status::ServiceStatusRegistry;
use anyhow::Result; use anyhow::Result;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
@@ -10,6 +11,7 @@ use std::sync::Arc;
pub struct AppState { pub struct AppState {
pub banner_api: Arc<BannerApi>, pub banner_api: Arc<BannerApi>,
pub db_pool: PgPool, pub db_pool: PgPool,
pub service_statuses: ServiceStatusRegistry,
} }
impl AppState { impl AppState {
@@ -17,6 +19,7 @@ impl AppState {
Self { Self {
banner_api, banner_api,
db_pool, db_pool,
service_statuses: ServiceStatusRegistry::new(),
} }
} }
+60
View File
@@ -0,0 +1,60 @@
use std::sync::Arc;
use std::time::Instant;
use dashmap::DashMap;
use serde::Serialize;
/// Health status of a service.
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ServiceStatus {
Starting,
Active,
Connected,
Disabled,
Error,
}
/// A timestamped status entry for a service.
#[derive(Debug, Clone)]
pub struct StatusEntry {
pub status: ServiceStatus,
pub updated_at: Instant,
}
/// Thread-safe registry for services to self-report their health status.
#[derive(Debug, Clone, Default)]
pub struct ServiceStatusRegistry {
inner: Arc<DashMap<String, StatusEntry>>,
}
impl ServiceStatusRegistry {
/// Creates a new empty registry.
pub fn new() -> Self {
Self::default()
}
/// Inserts or updates the status for a named service.
pub fn set(&self, name: &str, status: ServiceStatus) {
self.inner.insert(
name.to_owned(),
StatusEntry {
status,
updated_at: Instant::now(),
},
);
}
/// Returns the current status of a named service, if present.
pub fn get(&self, name: &str) -> Option<ServiceStatus> {
self.inner.get(name).map(|entry| entry.status.clone())
}
/// Returns a snapshot of all service statuses.
pub fn all(&self) -> Vec<(String, ServiceStatus)> {
self.inner
.iter()
.map(|entry| (entry.key().clone(), entry.value().status.clone()))
.collect()
}
}
+31 -51
View File
@@ -3,7 +3,7 @@
use axum::{ use axum::{
Router, Router,
body::Body, body::Body,
extract::Request, extract::{Request, State},
response::{Json, Response}, response::{Json, Response},
routing::get, routing::get,
}; };
@@ -17,6 +17,9 @@ use http::header;
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{collections::BTreeMap, time::Duration}; use std::{collections::BTreeMap, time::Duration};
use crate::state::AppState;
use crate::status::ServiceStatus;
#[cfg(not(feature = "embed-assets"))] #[cfg(not(feature = "embed-assets"))]
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer}; use tower_http::{classify::ServerErrorsFailureClass, timeout::TimeoutLayer, trace::TraceLayer};
@@ -63,11 +66,12 @@ fn set_caching_headers(response: &mut Response, path: &str, etag: &str) {
} }
/// Creates the web server router /// Creates the web server router
pub fn create_router() -> Router { pub fn create_router(app_state: AppState) -> Router {
let api_router = Router::new() let api_router = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.route("/status", get(status)) .route("/status", get(status))
.route("/metrics", get(metrics)); .route("/metrics", get(metrics))
.with_state(app_state);
let mut router = Router::new().nest("/api", api_router); let mut router = Router::new().nest("/api", api_router);
@@ -155,7 +159,7 @@ async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap)
// Check if client has a matching ETag (conditional request) // Check if client has a matching ETag (conditional request)
if let Some(etag) = request_headers.get(header::IF_NONE_MATCH) if let Some(etag) = request_headers.get(header::IF_NONE_MATCH)
&& metadata.etag_matches(etag.to_str().unwrap()) && etag.to_str().is_ok_and(|s| metadata.etag_matches(s))
{ {
return StatusCode::NOT_MODIFIED.into_response(); return StatusCode::NOT_MODIFIED.into_response();
} }
@@ -191,7 +195,7 @@ async fn handle_spa_fallback_with_headers(uri: Uri, request_headers: HeaderMap)
// Check if client has a matching ETag for index.html // Check if client has a matching ETag for index.html
if let Some(etag) = request_headers.get(header::IF_NONE_MATCH) if let Some(etag) = request_headers.get(header::IF_NONE_MATCH)
&& metadata.etag_matches(etag.to_str().unwrap()) && etag.to_str().is_ok_and(|s| metadata.etag_matches(s))
{ {
return StatusCode::NOT_MODIFIED.into_response(); return StatusCode::NOT_MODIFIED.into_response();
} }
@@ -217,70 +221,46 @@ async fn health() -> Json<Value> {
})) }))
} }
#[derive(Serialize)]
enum Status {
Disabled,
Connected,
Active,
Healthy,
Error,
}
#[derive(Serialize)] #[derive(Serialize)]
struct ServiceInfo { struct ServiceInfo {
name: String, name: String,
status: Status, status: ServiceStatus,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct StatusResponse { struct StatusResponse {
status: Status, status: ServiceStatus,
version: String, version: String,
commit: String, commit: String,
services: BTreeMap<String, ServiceInfo>, services: BTreeMap<String, ServiceInfo>,
} }
/// Status endpoint showing bot and system status /// Status endpoint showing bot and system status
async fn status() -> Json<StatusResponse> { async fn status(State(state): State<AppState>) -> Json<StatusResponse> {
let mut services = BTreeMap::new(); let mut services = BTreeMap::new();
// Bot service status - hardcoded as disabled for now for (name, svc_status) in state.service_statuses.all() {
services.insert( services.insert(
"bot".to_string(), name.clone(),
ServiceInfo { ServiceInfo {
name: "Bot".to_string(), name,
status: Status::Disabled, status: svc_status,
}, },
); );
}
// Banner API status - always connected for now let overall_status = if services.values().any(|s| matches!(s.status, ServiceStatus::Error)) {
services.insert( ServiceStatus::Error
"banner".to_string(), } else if !services.is_empty()
ServiceInfo { && services
name: "Banner".to_string(), .values()
status: Status::Connected, .all(|s| matches!(s.status, ServiceStatus::Active | ServiceStatus::Connected))
},
);
// Discord status - hardcoded as disabled for now
services.insert(
"discord".to_string(),
ServiceInfo {
name: "Discord".to_string(),
status: Status::Disabled,
},
);
let overall_status = if services.values().any(|s| matches!(s.status, Status::Error)) {
Status::Error
} else if services
.values()
.all(|s| matches!(s.status, Status::Active | Status::Connected))
{ {
Status::Active ServiceStatus::Active
} else if services.is_empty() {
ServiceStatus::Disabled
} else { } else {
// If we have any Disabled services but no errors, show as Healthy ServiceStatus::Active
Status::Healthy
}; };
Json(StatusResponse { Json(StatusResponse {
+7 -5
View File
@@ -31,11 +31,13 @@ describe("BannerApiClient", () => {
it("should fetch status data", async () => { it("should fetch status data", async () => {
const mockStatus = { const mockStatus = {
status: "operational", status: "active",
bot: { status: "running", uptime: "1h" }, version: "0.3.4",
cache: { status: "connected", courses: "100", subjects: "50" }, commit: "abc1234",
banner_api: { status: "connected" }, services: {
timestamp: "2024-01-01T00:00:00Z", web: { name: "web", status: "active" },
database: { name: "database", status: "connected" },
},
}; };
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(fetch).mockResolvedValueOnce({
+1 -1
View File
@@ -6,7 +6,7 @@ export interface HealthResponse {
timestamp: string; timestamp: string;
} }
export type Status = "Disabled" | "Connected" | "Active" | "Healthy" | "Error"; export type Status = "starting" | "active" | "connected" | "disabled" | "error";
export interface ServiceInfo { export interface ServiceInfo {
name: string; name: string;
+12 -9
View File
@@ -37,6 +37,9 @@ const SERVICE_ICONS: Record<string, typeof Bot> = {
bot: Bot, bot: Bot,
banner: Globe, banner: Globe,
discord: MessageCircle, discord: MessageCircle,
database: Activity,
web: Globe,
scraper: Clock,
}; };
interface ResponseTiming { interface ResponseTiming {
@@ -80,11 +83,11 @@ const formatNumber = (num: number): string => {
const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => { const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => {
const statusMap: Record<Status | "Unreachable", StatusIcon> = { const statusMap: Record<Status | "Unreachable", StatusIcon> = {
Active: { icon: CheckCircle, color: "green" }, active: { icon: CheckCircle, color: "green" },
Connected: { icon: CheckCircle, color: "green" }, connected: { icon: CheckCircle, color: "green" },
Healthy: { icon: CheckCircle, color: "green" }, starting: { icon: Hourglass, color: "orange" },
Disabled: { icon: Circle, color: "gray" }, disabled: { icon: Circle, color: "gray" },
Error: { icon: XCircle, color: "red" }, error: { icon: XCircle, color: "red" },
Unreachable: { icon: WifiOff, color: "red" }, Unreachable: { icon: WifiOff, color: "red" },
}; };
@@ -93,9 +96,9 @@ const getStatusIcon = (status: Status | "Unreachable"): StatusIcon => {
const getOverallHealth = (state: StatusState): Status | "Unreachable" => { const getOverallHealth = (state: StatusState): Status | "Unreachable" => {
if (state.mode === "timeout") return "Unreachable"; if (state.mode === "timeout") return "Unreachable";
if (state.mode === "error") return "Error"; if (state.mode === "error") return "error";
if (state.mode === "response") return state.status.status; if (state.mode === "response") return state.status.status;
return "Error"; return "error";
}; };
const getServices = (state: StatusState): Service[] => { const getServices = (state: StatusState): Service[] => {
@@ -116,8 +119,8 @@ const StatusDisplay = ({ status }: { status: Status | "Unreachable" }) => {
<Text <Text
size="2" size="2"
style={{ style={{
color: status === "Disabled" ? "var(--gray-11)" : undefined, color: status === "disabled" ? "var(--gray-11)" : undefined,
opacity: status === "Disabled" ? 0.7 : undefined, opacity: status === "disabled" ? 0.7 : undefined,
}} }}
> >
{status} {status}