From 8fd41b1090b87124a354e2219f0b79a82d4b81f3 Mon Sep 17 00:00:00 2001 From: Nicholas Orlowsky Date: Sun, 21 Sep 2025 19:53:11 -0400 Subject: [PATCH] clean up + update dfile --- Dockerfile | 9 +- api/src/controllers/mod.rs | 1 + api/src/controllers/route.rs | 126 +++++++++++++ api/src/database.rs | 26 +-- api/src/main.rs | 295 ++---------------------------- api/src/services/trip_tracking.rs | 21 +-- api/src/templates.rs | 152 +++++++++++++++ libseptastic/src/stop_schedule.rs | 19 +- 8 files changed, 314 insertions(+), 335 deletions(-) create mode 100644 api/src/controllers/mod.rs create mode 100644 api/src/controllers/route.rs create mode 100644 api/src/templates.rs diff --git a/Dockerfile b/Dockerfile index 5dd9b26..d39a18b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ -FROM rust:1.90-bullseye AS build +FROM rust:1.90-slim-trixie AS build ENV PKG_CONFIG_ALLOW_CROSS=1 +ENV SCCACHE_DIR=/build-cache +ENV RUSTC_WRAPPER=sccache WORKDIR . COPY ./api ./api @@ -9,9 +11,10 @@ COPY ./api/assets ./assets COPY ./api/templates ./templates +RUN apt -y update && apt install -y libssl-dev libc-dev sccache build-essential pkg-config RUN cd api && cargo build --release -FROM debian:bullseye-slim +FROM debian:trixie-slim WORKDIR /app EXPOSE 8080 @@ -19,7 +22,7 @@ COPY --from=build /api/target/release/septastic_api /app/septastic_api COPY api/assets /app/assets COPY api/templates /app/templates -RUN apt update && apt install curl +RUN apt -y update && apt install -y curl ENV RUST_LOG=info ENV EXPOSE_PORT=8080 diff --git a/api/src/controllers/mod.rs b/api/src/controllers/mod.rs new file mode 100644 index 0000000..b9bb57f --- /dev/null +++ b/api/src/controllers/mod.rs @@ -0,0 +1 @@ +pub mod route; diff --git a/api/src/controllers/route.rs b/api/src/controllers/route.rs new file mode 100644 index 0000000..6d5f391 --- /dev/null +++ b/api/src/controllers/route.rs @@ -0,0 +1,126 @@ +use actix_web::{get, web::{Data, self}, HttpResponse, Responder}; +use std::{cmp::Ordering, sync::Arc}; +use libseptastic::{route::RouteType, stop_schedule::Trip}; +use serde::{Serialize, Deserialize}; +use askama::Template; + +use crate::AppState; +use crate::database; + +#[get("/routes")] +async fn get_routes_html(state: Data>) -> impl Responder { + + let mut all_routes = database::get_all_routes(&mut state.database.begin().await.unwrap()).await.unwrap(); + all_routes.sort_by(|x, y| { + if let Ok(x_p) = x.id.parse::() { + if let Ok(y_p) = y.id.parse::() { + return if y_p > x_p { Ordering::Less } else { Ordering::Greater }; + } + } + + return if y.id > x.id { Ordering::Less } else { Ordering::Greater }; + }); + + HttpResponse::Ok().body(crate::templates::ContentTemplate { + page_title: None, + page_desc: None, + content: crate::templates::RoutesTemplate { + rr_routes: all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::RegionalRail).collect(), + subway_routes: all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::SubwayElevated).collect(), + trolley_routes: all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::Trolley).collect(), + bus_routes: all_routes.into_iter().filter(|x| x.route_type == RouteType::TracklessTrolley || x.route_type == RouteType::Bus).collect(), + } + }.render().unwrap()) +} + +#[get("/routes.json")] +async fn get_routes_json(state: Data>) -> impl Responder { + let all_routes = database::get_all_routes(&mut state.database.begin().await.unwrap()).await.unwrap(); + HttpResponse::Ok().json(all_routes) +} + +#[derive(Debug, Deserialize)] +pub struct MyQueryParams { + #[serde(default)] // Optional: handle missing parameters with a default value + stops: Option, +} + +async fn get_route_by_id(id: String, state: Data>) -> ::anyhow::Result { + Ok(database::get_route_by_id(id, &mut state.database.begin().await?).await?) +} + + +#[derive(Serialize, Deserialize)] +pub struct RouteResponse { + pub route: libseptastic::route::Route, + pub directions: Vec, + pub schedule: Vec +} + +async fn get_route_info(route_id: String, state: Data>) -> ::anyhow::Result { + let route = get_route_by_id(route_id.clone(), state.clone()).await?; + let directions = database::get_direction_by_route_id(route_id.clone(), &mut state.database.begin().await?).await?; + let mut trips = database::get_schedule_by_route_id(route_id.clone(), &mut state.database.begin().await?).await?; + + state.trip_tracking_service.annotate_trips(&mut trips); + + Ok(RouteResponse{ + route, + directions, + schedule: trips + }) +} + +#[get("/route/{route_id}")] +async fn get_route(state: Data>, info: web::Query, path: web::Path) -> impl Responder { + let mut filters: Option> = None; + if let Some (stops_v) = info.stops.clone() { + let mut items = Vec::new(); + + for sid in stops_v.split(",") { + items.push(sid.parse::().unwrap()); + } + filters = Some(items); + } + + let route_id = path.into_inner(); + let route_info_r = get_route_info(route_id, state).await; + if let Ok(route_info) = route_info_r { + HttpResponse::Ok().body(crate::templates::ContentTemplate { + page_title: None, + page_desc: None, + content: crate::templates::RouteTemplate { + route: route_info.route, + directions: route_info.directions.clone(), + timetables: crate::templates::build_timetables(route_info.directions.as_slice(), route_info.schedule.as_slice()), + filter_stops: filters.clone() + } + }.render().unwrap()) + } else { + HttpResponse::InternalServerError().body("Error") + } +} + +#[get("/route/{route_id}.json")] +async fn api_get_route(state: Data>, path: web::Path) -> impl Responder { + let route_id = path.into_inner(); + let route_info_r = get_route_info(route_id, state).await; + if let Ok(route_info) = route_info_r { + HttpResponse::Ok().json(route_info) + } else { + HttpResponse::InternalServerError().body("Error") + } +} + +#[get("/api/route/{route_id}/schedule")] +async fn api_get_schedule(state: Data>, path: web::Path) -> impl Responder { + let route_id = path.into_inner(); + let route_r = database::get_schedule_by_route_id(route_id, &mut state.database.begin().await.unwrap()).await; + + if let Ok(route) = route_r { + HttpResponse::Ok().json(route) + } else { + HttpResponse::InternalServerError().body("Error") + } +} + diff --git a/api/src/database.rs b/api/src/database.rs index 3666f41..c790ddc 100644 --- a/api/src/database.rs +++ b/api/src/database.rs @@ -2,8 +2,7 @@ use std::collections::HashMap; use libseptastic::{direction::CardinalDirection, route::RouteType}; use serde::{Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; - -use crate::services::trip_tracking::TripTracking; +use libseptastic::{stop_schedule::{Trip, TripTracking, StopSchedule}}; pub async fn get_route_by_id( id: String, @@ -104,29 +103,6 @@ pub async fn get_direction_by_route_id( return Ok(res); } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StopSchedule { - pub route_id: String, - pub stop_name: String, - pub trip_id: String, - pub service_id: String, - pub direction_id: i64, - pub arrival_time: i64, - pub stop_id: i64, - pub stop_sequence: i64 -} - - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Trip { - pub route_id: String, - pub trip_id: String, - pub direction_id: i64, - pub tracking_data: TripTracking, - pub schedule: Vec -} - - pub async fn get_schedule_by_route_id( id: String, transaction: &mut Transaction<'_, Postgres>, diff --git a/api/src/main.rs b/api/src/main.rs index 46f43de..89fc80b 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,301 +1,32 @@ use actix_web::{get, web::{self, Data}, App, HttpResponse, HttpServer, Responder}; use chrono::TimeDelta; -use database::{get_direction_by_route_id, get_nta_by_stop_id, get_schedule_by_route_id}; +use database::get_nta_by_stop_id; use env_logger::Env; -use libseptastic::{direction::Direction, route::RouteType}; -use database::{Trip}; use log::*; use dotenv::dotenv; -use services::trip_tracking::{self, TripTracking}; -use std::{cmp::Ordering, collections::BTreeMap, sync::Arc}; +use services::trip_tracking::{self}; +use std::sync::Arc; use askama::Template; -use serde::{Serialize, Deserialize}; -use crate::TripTracking::Tracked; mod database; mod services; +mod controllers; +mod templates; -struct AppState { +pub struct AppState { database: ::sqlx::postgres::PgPool, trip_tracking_service: services::trip_tracking::TripTrackingService } - -async fn get_route_by_id(id: String, state: Data>) -> ::anyhow::Result { - Ok(database::get_route_by_id(id, &mut state.database.begin().await?).await?) -} - - -#[derive(Debug, Serialize)] -pub struct TimetableStopRow { - pub stop_id: i64, - pub stop_name: String, - pub stop_sequence: i64, - pub times: Vec>, // one per trip, None if trip doesn't stop -} - - -#[derive(Debug, Serialize)] -pub struct TimetableDirection { - pub direction: Direction, - pub trip_ids: Vec, // column headers - pub tracking_data: Vec, - pub rows: Vec -} - -pub fn build_timetables( - directions: &[Direction], - trips: &[Trip], -) -> Vec { - let mut results = Vec::new(); - - for direction in directions { - let mut direction_trips: Vec<&Trip> = trips - .iter() - .filter(|trip| trip.direction_id == direction.direction_id) - .collect(); - - direction_trips.sort_by_key(|trip| { - trip.schedule - .iter() - .filter_map(|s| Some(s.arrival_time)) - .min() - .unwrap_or(i64::MAX) - }); - - let trip_ids: Vec = direction_trips - .iter() - .map(|t| t.trip_id.clone()) - .collect(); - - let live_trips: Vec = direction_trips - .iter() - .map(|t| t.tracking_data.clone()) - .collect(); - - - let mut stop_map: BTreeMap>)> = BTreeMap::new(); - - for (trip_index, trip) in direction_trips.iter().enumerate() { - for stop in &trip.schedule { - let entry = stop_map - .entry(stop.stop_id) - .or_insert((stop.stop_sequence, stop.stop_name.clone(), vec![None; direction_trips.len()])); - - // If this stop_id appears in multiple trips with different sequences, keep the lowest - entry.0 = entry.0.max(stop.stop_sequence); - entry.1 = stop.stop_name.clone(); - entry.2[trip_index] = Some(stop.arrival_time); - } - } - - let mut rows: Vec = stop_map - .into_iter() - .map(|(stop_id, (stop_sequence, stop_name, times))| TimetableStopRow { - stop_id, - stop_sequence, - stop_name, - times, - }) - .collect(); - - rows.sort_by(| a, b| { - if a.stop_sequence < b.stop_sequence { - Ordering::Less - } else { - Ordering::Greater - } - }); - - assert!(trip_ids.len() == live_trips.len()); - for row in &rows { - assert!(row.times.len() == live_trips.len()); - } - - - results.push(TimetableDirection { - direction: direction.clone(), - trip_ids, - rows, - tracking_data: live_trips - }); - } - - results -} - -mod filters { - pub fn format_time( - seconds: &i64, - _: &dyn askama::Values, - ) -> askama::Result { - let total_minutes = seconds / 60; - let (hours, ampm) = { - let hrs = total_minutes / 60; - if hrs > 12 { - (hrs - 12, "PM") - } else { - (hrs, "AM") - } - }; - let minutes = total_minutes % 60; - Ok(format!("{}:{:02} {}", hours, minutes, ampm)) - } -} - -#[derive(askama::Template)] -#[template(path = "layout.html")] -struct ContentTemplate { - content: T, - page_title: Option, - page_desc: Option, -} - -#[derive(askama::Template)] -#[template(path = "route.html")] -struct RouteTemplate { - route: libseptastic::route::Route, - directions: Vec, - timetables: Vec, - filter_stops: Option> -} - -#[derive(Serialize, Deserialize)] -struct RouteResponse { - route: libseptastic::route::Route, - directions: Vec, - schedule: Vec -} - -#[derive(askama::Template)] -#[template(path = "routes.html")] -struct RoutesTemplate { - rr_routes: Vec, - subway_routes: Vec, - trolley_routes: Vec, - bus_routes: Vec -} - -#[derive(askama::Template)] -#[template(path = "index.html")] -struct IndexTemplate { -} - -#[get("/routes")] -async fn get_routes_html(state: Data>) -> impl Responder { - - let mut all_routes = database::get_all_routes(&mut state.database.begin().await.unwrap()).await.unwrap(); - all_routes.sort_by(|x, y| { - if let Ok(x_p) = x.id.parse::() { - if let Ok(y_p) = y.id.parse::() { - return if y_p > x_p { Ordering::Less } else { Ordering::Greater }; - } - } - - return if y.id > x.id { Ordering::Less } else { Ordering::Greater }; - }); - - HttpResponse::Ok().body(ContentTemplate { - page_title: None, - page_desc: None, - content: RoutesTemplate { - rr_routes: all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::RegionalRail).collect(), - subway_routes: all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::SubwayElevated).collect(), - trolley_routes: all_routes.clone().into_iter().filter(|x| x.route_type == RouteType::Trolley).collect(), - bus_routes: all_routes.into_iter().filter(|x| x.route_type == RouteType::TracklessTrolley || x.route_type == RouteType::Bus).collect(), - } - }.render().unwrap()) -} - -#[get("/routes.json")] -async fn get_routes_json(state: Data>) -> impl Responder { - let all_routes = database::get_all_routes(&mut state.database.begin().await.unwrap()).await.unwrap(); - HttpResponse::Ok().json(all_routes) -} - #[get("/")] async fn get_index() -> impl Responder { - - HttpResponse::Ok().body(ContentTemplate { + HttpResponse::Ok().body(templates::ContentTemplate { page_title: None, page_desc: None, - content: IndexTemplate {} + content: templates::IndexTemplate {} }.render().unwrap()) } -#[derive(Debug, Deserialize)] - pub struct MyQueryParams { - #[serde(default)] // Optional: handle missing parameters with a default value - stops: Option, - } - -#[get("/route/{route_id}")] -async fn get_route(state: Data>, info: web::Query, path: web::Path) -> impl Responder { - let mut fils: Option> = None; - if let Some (stops_v) = info.stops.clone() { - let mut items = Vec::new(); - - for sid in stops_v.split(",") { - items.push(sid.parse::().unwrap()); - } - fils = Some(items); - } - let route_id = path.into_inner(); - let route_info_r = get_route_info(route_id, state).await; - if let Ok(route_info) = route_info_r { - HttpResponse::Ok().body(ContentTemplate { - page_title: None, - page_desc: None, - content: RouteTemplate { - route: route_info.route, - directions: route_info.directions.clone(), - timetables: build_timetables(route_info.directions.as_slice(), route_info.schedule.as_slice()), - filter_stops: fils.clone() - } - }.render().unwrap()) - } else { - HttpResponse::InternalServerError().body("Error") - } -} - - -async fn get_route_info(route_id: String, state: Data>) -> ::anyhow::Result { - let route = get_route_by_id(route_id.clone(), state.clone()).await?; - let directions = get_direction_by_route_id(route_id.clone(), &mut state.database.begin().await?).await?; - let mut trips = get_schedule_by_route_id(route_id.clone(), &mut state.database.begin().await?).await?; - - state.trip_tracking_service.annotate_trips(&mut trips); - - Ok(RouteResponse{ - route, - directions, - schedule: trips - }) -} - - -#[get("/route/{route_id}.json")] -async fn api_get_route(state: Data>, path: web::Path) -> impl Responder { - let route_id = path.into_inner(); - let route_info_r = get_route_info(route_id, state).await; - if let Ok(route_info) = route_info_r { - HttpResponse::Ok().json(route_info) - } else { - HttpResponse::InternalServerError().body("Error") - } -} - -#[get("/api/route/{route_id}/schedule")] -async fn api_get_schedule(state: Data>, path: web::Path) -> impl Responder { - let route_id = path.into_inner(); - let route_r = get_schedule_by_route_id(route_id, &mut state.database.begin().await.unwrap()).await; - if let Ok(route) = route_r { - HttpResponse::Ok().json(route) - } else { - HttpResponse::InternalServerError().body("Error") - } -} - #[get("/api/stop/{stop_id}/nta")] async fn api_get_nta(state: Data>, path: web::Path) -> impl Responder { let route_id = path.into_inner().split(',') .map(|s| s.parse::()) @@ -339,12 +70,12 @@ async fn main() -> ::anyhow::Result<()> { App::new() .wrap(actix_cors::Cors::permissive()) .app_data(Data::new(state.clone())) - .service(api_get_route) - .service(api_get_schedule) .service(api_get_nta) - .service(get_route) - .service(get_routes_json) - .service(get_routes_html) + .service(controllers::route::api_get_route) + .service(controllers::route::api_get_schedule) + .service(controllers::route::get_route) + .service(controllers::route::get_routes_json) + .service(controllers::route::get_routes_html) .service(get_index) .service(actix_files::Files::new("/assets", "./assets")) }) diff --git a/api/src/services/trip_tracking.rs b/api/src/services/trip_tracking.rs index 026b664..24d0258 100644 --- a/api/src/services/trip_tracking.rs +++ b/api/src/services/trip_tracking.rs @@ -6,21 +6,7 @@ use std::collections::HashMap; use std::time::Duration; use log::{error, info}; use serde::{Serialize, Deserialize, Deserializer}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum TripTracking { - Tracked(LiveTrip), - Untracked, - Cancelled -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LiveTrip { - pub delay: f64, - pub next_stop_id: Option, - pub timestamp: i64, - pub vehicle_id: Option -} +use libseptastic::stop_schedule::{LiveTrip, TripTracking}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LiveTripJson { @@ -71,8 +57,6 @@ impl TripTrackingService { let cloned_state = Arc::clone(&self.state); thread::spawn( move || { loop { - info!("started"); - let clonedx_state = Arc::clone(&cloned_state); let res = Self::update_live_trips(clonedx_state); @@ -88,7 +72,7 @@ impl TripTrackingService { }); } - pub fn annotate_trips(&self, trips: &mut Vec) { + pub fn annotate_trips(&self, trips: &mut Vec) { for trip in trips { trip.tracking_data = match self.state.lock().unwrap().tracking_data.get(&trip.trip_id.clone()){ Some(x) => x.clone(), @@ -128,7 +112,6 @@ impl TripTrackingService { ); } - info!("populated tracking data with {} entries", new_map.len()); (service.lock().unwrap()).tracking_data = new_map; Ok(()) diff --git a/api/src/templates.rs b/api/src/templates.rs new file mode 100644 index 0000000..194358f --- /dev/null +++ b/api/src/templates.rs @@ -0,0 +1,152 @@ +use libseptastic::{direction::Direction, stop_schedule::{Trip, TripTracking}}; +use std::{cmp::Ordering, collections::BTreeMap}; +use serde::{Serialize}; +use libseptastic::stop_schedule::TripTracking::Tracked; + +#[derive(askama::Template)] +#[template(path = "layout.html")] +pub struct ContentTemplate { + pub content: T, + pub page_title: Option, + pub page_desc: Option, +} + +#[derive(askama::Template)] +#[template(path = "route.html")] +pub struct RouteTemplate { + pub route: libseptastic::route::Route, + pub directions: Vec, + pub timetables: Vec, + pub filter_stops: Option> +} + +#[derive(askama::Template)] +#[template(path = "routes.html")] +pub struct RoutesTemplate { + pub rr_routes: Vec, + pub subway_routes: Vec, + pub trolley_routes: Vec, + pub bus_routes: Vec +} + +#[derive(askama::Template)] +#[template(path = "index.html")] +pub struct IndexTemplate { +} + +#[derive(Debug, Serialize)] +pub struct TimetableStopRow { + pub stop_id: i64, + pub stop_name: String, + pub stop_sequence: i64, + pub times: Vec> +} + + +#[derive(Debug, Serialize)] +pub struct TimetableDirection { + pub direction: Direction, + pub trip_ids: Vec, + pub tracking_data: Vec, + pub rows: Vec +} + +pub fn build_timetables( + directions: &[Direction], + trips: &[Trip], +) -> Vec { + let mut results = Vec::new(); + + for direction in directions { + let mut direction_trips: Vec<&Trip> = trips + .iter() + .filter(|trip| trip.direction_id == direction.direction_id) + .collect(); + + direction_trips.sort_by_key(|trip| { + trip.schedule + .iter() + .filter_map(|s| Some(s.arrival_time)) + .min() + .unwrap_or(i64::MAX) + }); + + let trip_ids: Vec = direction_trips + .iter() + .map(|t| t.trip_id.clone()) + .collect(); + + let live_trips: Vec = direction_trips + .iter() + .map(|t| t.tracking_data.clone()) + .collect(); + + + let mut stop_map: BTreeMap>)> = BTreeMap::new(); + + for (trip_index, trip) in direction_trips.iter().enumerate() { + for stop in &trip.schedule { + let entry = stop_map + .entry(stop.stop_id) + .or_insert((stop.stop_sequence, stop.stop_name.clone(), vec![None; direction_trips.len()])); + + // If this stop_id appears in multiple trips with different sequences, keep the lowest + entry.0 = entry.0.max(stop.stop_sequence); + entry.1 = stop.stop_name.clone(); + entry.2[trip_index] = Some(stop.arrival_time); + } + } + + let mut rows: Vec = stop_map + .into_iter() + .map(|(stop_id, (stop_sequence, stop_name, times))| TimetableStopRow { + stop_id, + stop_sequence, + stop_name, + times, + }) + .collect(); + + rows.sort_by(| a, b| { + if a.stop_sequence < b.stop_sequence { + Ordering::Less + } else { + Ordering::Greater + } + }); + + assert!(trip_ids.len() == live_trips.len()); + for row in &rows { + assert!(row.times.len() == live_trips.len()); + } + + + results.push(TimetableDirection { + direction: direction.clone(), + trip_ids, + rows, + tracking_data: live_trips + }); + } + + results +} + +mod filters { + pub fn format_time( + seconds_since_midnight: &i64, + _: &dyn askama::Values, + ) -> askama::Result { + let total_minutes = seconds_since_midnight / 60; + let (hours, ampm) = { + let hrs = total_minutes / 60; + if hrs > 12 { + (hrs - 12, "PM") + } else { + (hrs, "AM") + } + }; + let minutes = total_minutes % 60; + Ok(format!("{}:{:02} {}", hours, minutes, ampm)) + } +} diff --git a/libseptastic/src/stop_schedule.rs b/libseptastic/src/stop_schedule.rs index 5433d29..4d367c9 100644 --- a/libseptastic/src/stop_schedule.rs +++ b/libseptastic/src/stop_schedule.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StopSchedule { pub route_id: String, + pub stop_name: String, pub trip_id: String, pub service_id: String, pub direction_id: i64, @@ -16,15 +17,21 @@ pub struct Trip { pub route_id: String, pub trip_id: String, pub direction_id: i64, - pub live_trip_data: Option, + pub tracking_data: TripTracking, pub schedule: Vec } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LiveTrip { - pub delay: i64, - pub next_stop_id: i64, - pub timestamp: i64, - pub vehicle_id: String +pub enum TripTracking { + Tracked(LiveTrip), + Untracked, + Cancelled } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiveTrip { + pub delay: f64, + pub next_stop_id: Option, + pub timestamp: i64, + pub vehicle_id: Option +}