diff --git a/README.md b/README.md index 97c9575..24e1e24 100755 --- a/README.md +++ b/README.md @@ -6,9 +6,12 @@ ## Makefile * `make` or `make help` to list all commands * `make docker-up` to start all containers +* `make docker-refresh` to start the background services * `make docker-clean` to stop and delete all containers, volumes, and networks related to the application +**WARNING**: Running `make docker-clean` or `make docker-refresh` will wipe the database, redis, and minio data + ## Setup 1. Override any environment variables in `.env.local` @@ -20,7 +23,16 @@ to the application * Running just `make cert` will generate `localhost` certificates 4. Run the application with `make up` +### Development Environment +Start background services with `make docker-refresh` +* Note: when `ENVIRONMENT` is not set to `production` (i.e., set to `development`), + the nginx container will function only as a reverse proxy - the UI must be run through `make run-ui` + +Start the UI through `make run-ui` and the API through `make run-api` + ### Production Environment +Start with `make docker-up` + The most likely to change environment variables are the following: * `UI_PORT` * `API_PORT` @@ -71,3 +83,7 @@ The following resources were used to help decode METARS. ### Other data - https://www.faa.gov/air_traffic/weather/asos + +## Tests +`cargo test metars::model::tests::test_parse_time -- --exact --nocapture +` diff --git a/api/src/account/routes.rs b/api/src/account/routes.rs index df4553a..6f3e4fb 100644 --- a/api/src/account/routes.rs +++ b/api/src/account/routes.rs @@ -134,7 +134,7 @@ async fn validate_session(req: HttpRequest) -> HttpResponse { let session_id = cookie.value().to_string(); let session = match Session::replace(&session_id, &ip_address).await { Ok(session) => session, - Err(err) => { + Err(_) => { log::error!( "Invalid session validate attempt [Session: {}] [IP Address: {}]", session_id, diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index e2024e4..8548795 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -1,5 +1,7 @@ use crate::error::ApiResult; -use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult}; +use redis::{ + Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult, +}; use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData}; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; @@ -129,10 +131,10 @@ fn redis() -> &'static RedisClient { REDIS.get().unwrap() } -pub fn redis_connection() -> RedisResult { - let conn = redis().get_connection()?; - Ok(conn) -} +// pub fn redis_connection() -> RedisResult { +// let conn = redis().get_connection()?; +// Ok(conn) +// } pub async fn redis_async_connection() -> RedisResult { let conn = redis().get_multiplexed_async_connection().await?; diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index 3bbd484..b1b2454 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -1,6 +1,6 @@ use crate::error::Error; use crate::{error::ApiResult, db}; -use chrono::{DateTime, Datelike, Utc}; +use chrono::{DateTime, Datelike, NaiveDate, Utc}; use std::collections::HashSet; use std::env; use std::fmt::Display; @@ -338,49 +338,7 @@ impl Metar { // Date/Time let observation_time = metar_parts[0]; metar_parts.remove(0); - if observation_time.len() != 7 { - return Err(Error::new( - 500, - format!( - "Unable to parse observation time in {}: {}", - observation_time, metar_string - ), - )); - } - let observation_time_day = match observation_time[0..2].parse::() { - Ok(day) => day, - Err(err) => return Err(err.into()), - }; - let observation_time_hour = match observation_time[2..4].parse::() { - Ok(hour) => hour, - Err(err) => return Err(err.into()), - }; - let observation_time_minute = match observation_time[4..6].parse::() { - Ok(minute) => minute, - Err(err) => return Err(err.into()), - }; - let current_time = Utc::now().naive_utc(); - - // Check if the observation time is from the previous month - let observation_time_month = if current_time.day() > observation_time_day { - current_time.month() - 1 - } else { - current_time.month() - }; - // Check if the observation time is from the previous year - let observation_time_year = if current_time.month() > observation_time_month { - current_time.year() - 1 - } else { - current_time.year() - }; - let observation_time = format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:00Z", - observation_time_year, - observation_time_month, - observation_time_day, - observation_time_hour, - observation_time_minute - ); + let observation_time = Self::parse_time(observation_time)?; metar.observation_time = match chrono::DateTime::parse_from_rfc3339(&observation_time) { Ok(datetime) => datetime.with_timezone(&Utc), Err(err) => return Err(err.into()), @@ -928,6 +886,49 @@ impl Metar { Ok(metar) } + fn parse_time(observation_time: &str) -> ApiResult { + if observation_time.len() != 7 { + return Err(Error::new( + 500, + format!("Unable to parse observation time in {}", observation_time), + )); + } + let observation_day = match observation_time[0..2].parse::() { + Ok(day) => day, + Err(err) => return Err(err.into()), + }; + let observation_hour = match observation_time[2..4].parse::() { + Ok(hour) => hour, + Err(err) => return Err(err.into()), + }; + let observation_minute = match observation_time[4..6].parse::() { + Ok(minute) => minute, + Err(err) => return Err(err.into()), + }; + let current_time = Utc::now().naive_utc(); + let current_year = current_time.year(); + let current_month = current_time.month(); + let candidate_date = NaiveDate::from_ymd_opt(current_year, current_month, observation_day) + .ok_or_else(|| Error::new(500, format!("Invalid date with day {} for current month", observation_day)))? + .and_hms_opt(observation_hour, observation_minute, 0).unwrap(); + + let obs_datetime = if candidate_date > current_time { + // Subtract one month. (Handle year rollover carefully.) + let (month, year) = if current_month == 1 { + (12, current_year - 1) + } else { + (current_month - 1, current_year) + }; + + let adjusted_date = NaiveDate::from_ymd_opt(year, month, observation_day) + .ok_or_else(|| Error::new(500, format!("Invalid date with day {} for month {}", observation_day, month)))?; + adjusted_date.and_hms(observation_hour, observation_minute, 0) + } else { + candidate_date + }; + Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string()) + } + async fn get_missing_metar_icaos( db_metars: &Vec, station_icaos: &Vec, @@ -961,7 +962,7 @@ impl Metar { .collect::>(); let mut metars: Vec = vec![]; for icao_chunk in icao_chunks { - let url = format!("{}/metar?ids={}&order=id", base_url, icao_chunk); + let url = format!("{}/metar?ids={}&hours=0&order=id,-obs", base_url, icao_chunk); let mut m = match client.get(url).send().await { Ok(r) => { // Check if the status code is 200 @@ -1049,7 +1050,6 @@ impl Metar { let result: RedisResult> = conn.get(icao).await; match result { Ok(Some(value)) => { - log::info!("{}: {}", icao, value); if value { updated_missing_icao_list.push(icao); } @@ -1105,6 +1105,7 @@ impl Metar { } pub async fn insert(&self) -> ApiResult<()> { + log::trace!("Inserting metar {} with observation time {}", self.icao, self.observation_time); let metar: MetarRow = self.to_db()?; metar.insert().await?; Ok(()) @@ -1113,26 +1114,68 @@ impl Metar { #[cfg(test)] mod tests { + use chrono::NaiveDateTime; use super::*; #[test] - fn test_metar() { + fn test_parse_time() { + for day in 1..=31 { + for hour in 0..24 { + for minute in 0..60 { + // METAR form "DDHHMMZ" + let obs_time = format!("{:02}{:02}{:02}Z", day, hour, minute); + let result = Metar::parse_time(&obs_time); + match result { + Ok(datetime_str) => { + // "YYYY-MM-DDTHH:MM:00Z" + assert_eq!( + datetime_str.len(), + 20, + "Unexpected length for input {} yielded {}", + obs_time, + datetime_str + ); + // Remove the trailing 'Z' and parse + let trimmed = &datetime_str[..19]; + NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") + .unwrap_or_else(|e| { + panic!( + "Parsing '{}' from input {} failed: {}", + trimmed, obs_time, e + ) + }); + } + Err(_err) => {} + } + } + } + } + } + + #[tokio::test] + async fn test_metar() { let mut metar_string = "METAR KABC 121755Z AUTO 21016G24KT 180V240 1SM R11/P6000FT -RA BR BKN015 OVC025 06/04 A2990 RMK AO2 PK WND 20032/25 WSHFT 1715 VIS 3/4V1 1/2 VIS 3/4 RWY11 RAB07 CIG 013V017 CIG 017 RWY11 PRESFR SLP125 P0003 60009 T00640036 10066 21012 58033 TSNO $".to_string(); let metar = Metar::parse(&metar_string).unwrap(); - // dbg!(&metar); + dbg!(&metar.observation_time); metar_string = "KMIA 090053Z 33004KT 10SM FEW015 FEW024 SCT075 SCT250 25/22 A2990 RMK AO2 SLP126 T02500217 $".to_string(); let metar = Metar::parse(&metar_string).unwrap(); - // dbg!(&metar); + dbg!(&metar.observation_time); metar_string = "KMRB 082253Z 30014G23KT 10SM CLR 05/M12 A3002 RMK AO2 PK WND 30028/2157 SLP168 T00501117" .to_string(); let metar = Metar::parse(&metar_string).unwrap(); - // dbg!(&metar); + dbg!(&metar.observation_time); - // metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string(); + metar_string = "KHEF 092356Z 13009KT 10SM CLR 08/M03 A3022 RMK AO2 SLP239 6//// T00831033 10133 20078 53002 PNO $".to_string(); + let metar = Metar::parse(&metar_string).unwrap(); + dbg!(&metar.observation_time); + + metar_string = "KSLK 162351Z AUTO VRB03KT 1SM -SN BR FEW007 OVC014 00/M02 A2974 RMK AO2 SLP090 P0001 60004 T00001017 10000 21011 53026".to_string(); + let metar = Metar::parse(&metar_string).unwrap(); + dbg!(&metar.observation_time); } } diff --git a/bruno/Metars/Find Metars.bru b/bruno/Metars/Find Metars.bru index 319fe8f..97a132e 100644 --- a/bruno/Metars/Find Metars.bru +++ b/bruno/Metars/Find Metars.bru @@ -5,12 +5,11 @@ meta { } get { - url: {{API_URL}}/metars?icaos=KJYO,KOKV,KMRB,KHEF,KIAD&force=true + url: {{API_URL}}/metars?icaos=KHEF,KJYO body: none auth: none } params:query { - icaos: KJYO,KOKV,KMRB,KHEF,KIAD - force: true + icaos: KHEF,KJYO } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1d2aa4e..fed944c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -25,9 +25,17 @@ L.Icon.Default.mergeOptions({ shadowUrl: markerShadow }); -const openStreetMapUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; -const lightLayerUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'; -const darkLayerUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'; +export interface LayerInfo { + url: string; + name: string; + markerOutline: string; +} + +const layerMap: LayerInfo[] = [ + { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', name: 'Open Street Map', markerOutline: 'black' }, + { url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', name: 'Carto Light', markerOutline: 'black' }, + { url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', name: 'Carto Dark', markerOutline: 'white'}, +] // const dark1Url = 'https://maps.rainviewer.com/data/v3/5/10/11.pbf'; // const dark2Url = 'https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer/tile/2/0/3.pbf'; const defaultZoom = 6; @@ -38,7 +46,10 @@ function App() { const [rainViewerUrl, setRainViewerUrl] = useState(null); const initialRadarValue = Cookies.get('showRadar') === 'true'; const [showRadar, setShowRadar] = useState(initialRadarValue); - const [baseLayer, setBaseLayer] = useState(Cookies.get('selectedBaseLayer') || 'Open Street Map'); + const initialShowNoMetarValue = Cookies.get('showNoMetar') === 'true'; + const [showNoMetar, setShowNoMetar] = useState(initialShowNoMetarValue); + const [selectedLayerIndex, setSelectedLayerIndex] = useState(Cookies.get('selectedLayer') || '0'); + const [selectedLayer, setSelectedLayer] = useState(layerMap[Number(selectedLayerIndex)]); useEffect(() => { if (showRadar) { @@ -56,11 +67,21 @@ function App() { }); } + function toggleShowNoMetar() { + setShowNoMetar((prev) => { + const newValue = !prev; + Cookies.set('showNoMetar', newValue.toString(), { expires: 7 }); + return newValue; + }); + } + function BaseLayerChangeHandler() { useMapEvents({ baselayerchange: (e) => { - setBaseLayer(e.name); - Cookies.set('selectedBaseLayer', e.name, { expires: 7 }); + const index = layerMap.findIndex(layer => layer.name === e.name); + setSelectedLayerIndex(`${index}`); + Cookies.set('selectedLayer', `${index}`, { expires: 7 }); + setSelectedLayer(layerMap[index]); } }); return null; @@ -86,21 +107,24 @@ function App() { zoomControl={false} > - - - - - - - - - + {layerMap.map((layer, index) => ( + + + + ))} {rainViewerUrl && showRadar && } - + + + U + void; }) { const [metar, setMetar] = useState(undefined); + const isMobile = useMediaQuery('(max-width: 768px)'); useEffect(() => { - if (airport != null) { + if (!airport) return; + function updateMetar() { + if (!airport) return; + console.log(airport.icao); getMetars({ icaos: [airport.icao] }).then((m) => { if (m.length > 0) { setMetar(m[0]); + } else { + setMetar(undefined); } }); } + + updateMetar(); + + const interval = setInterval(updateMetar, 60000); + + return () => clearInterval(interval); }, [airport]); if (!airport) { @@ -35,47 +48,58 @@ export default function AirportDrawer({ onClose={() => setAirport(null)} title={airport.name} withinPortal - zIndex={10000} - styles={{ root: { width: 0, height: 0 } }} + zIndex={1000} + styles={{ root: { padding: 0, margin: 0, width: 0, height: 0 } }} padding='md' - size='md' + size={isMobile ? '100%' : 'md'} position='left' withOverlay={false} closeOnClickOutside={false} > {metar && metar.flight_category && ( - + {metar.flight_category} - {metar.observation_time ? new Date(metar.observation_time).toLocaleString() : 'N/A'} + + + )} - -
ICAO: {airport.icao}
-
Category: {airportCategoryToText(airport.category)}
-
- Country / Region: {airport.iso_country}, {airport.iso_region} -
-
Municipality: {airport.municipality || 'N/A'}
-
Local Code: {airport.local || 'N/A'}
-
Elevation: {airport.elevation_ft}
-
- Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)} -
-
Control Tower: {airport.has_tower ? 'Yes' : 'No'}
-
Beacon: {airport.has_beacon ? 'Yes' : 'No'}
- {metar && metar.flight_category && ( - <> - -
Flight Category: {metar.flight_category}
- + + + Info + Weather + + + {airport.latest_metar && ( + )} -
+
); } +function AirportInfo({ airport }: { airport: Airport }) { + return (
+ ICAO: {airport.icao} + Category: {airportCategoryToText(airport.category)} +
); +} + +function WeatherInfo({ metar }: { metar: Metar }) { + return <>{metar.raw_text} +} + function airportCategoryToText(category: AirportCategory): string { switch (category) { case AirportCategory.SMALL: @@ -96,3 +120,19 @@ function airportCategoryToText(category: AirportCategory): string { return 'Unknown'; } } + +function TimeSince({ date }: { date: string }) { + const inputDate = new Date(date); + // @ts-expect-error doing arithmetic with dates + const seconds = Math.floor((new Date() - inputDate) / 1000); + + if (seconds < 60) { + const content = seconds + (seconds === 1 ? " second ago" : " seconds ago"); + return {content}; + } else { + const minutes = Math.floor(seconds / 60); + const content = minutes + (minutes === 1 ? " minute ago" : " minutes ago"); + // If more than 60 minutes have passed, set the text color to yellow + return = 60 ? '#fca903' : undefined }}>{content}; + } +} diff --git a/ui/src/components/AirportLayer.tsx b/ui/src/components/AirportLayer.tsx index 4b37fb3..a3ac8e6 100644 --- a/ui/src/components/AirportLayer.tsx +++ b/ui/src/components/AirportLayer.tsx @@ -4,13 +4,22 @@ import { useMapEvents } from 'react-leaflet'; import { getAirports } from '@lib/airport.ts'; import AirportMarker from '@components/AirportMarker.tsx'; import { LeafletEvent } from 'leaflet'; +import { LayerInfo } from '@/App.tsx'; interface Bounds { northEast: { lat: number; lon: number }; southWest: { lat: number; lon: number }; } -export default function AirportLayer({ setAirport }: { setAirport: (airport: Airport) => void }) { +export default function AirportLayer({ + setAirport, + showNoMetar, + selectedLayer +}: { + setAirport: (airport: Airport) => void; + showNoMetar: boolean; + selectedLayer: LayerInfo; +}) { const [airports, setAirports] = useState([]); function loadAirports(event: LeafletEvent) { @@ -52,40 +61,44 @@ export default function AirportLayer({ setAirport }: { setAirport: (airport: Air } }, [map]); - const categoryOrder: { [key in AirportCategory]?: number } = { - [AirportCategory.LARGE]: 3, - [AirportCategory.MEDIUM]: 2, - [AirportCategory.SMALL]: 1, - [AirportCategory.HELIPORT]: 0 - }; + // const categoryOrder: { [key in AirportCategory]?: number } = { + // [AirportCategory.LARGE]: 3, + // [AirportCategory.MEDIUM]: 2, + // [AirportCategory.SMALL]: 1, + // [AirportCategory.HELIPORT]: 0 + // }; - const sortedAirports = airports.slice().sort((a, b) => { - // Compare by airport category first. - const categoryA = categoryOrder[a.category] ?? 4; - const categoryB = categoryOrder[b.category] ?? 4; - if (categoryA !== categoryB) { - return categoryA - categoryB; - } - - // Then compare by flight category if available. - // Assuming that latest_metar.flight_category is a string and "UNKN" needs to come last. - const fcA = a.latest_metar?.flight_category ?? 'UNKN'; - const fcB = b.latest_metar?.flight_category ?? 'UNKN'; - - if (fcA === 'UNKN' && fcB !== 'UNKN') return 1; - if (fcB === 'UNKN' && fcA !== 'UNKN') return -1; - - // If both flight categories are not "UNKN", do a simple alphabetical comparison. - // (You may wish to customize this logic based on the actual flight category values.) - if (fcA < fcB) return -1; - if (fcA > fcB) return 1; - return 0; - }); + // const sortedAirports = airports.slice().sort((a, b) => { + // // Compare by airport category first. + // const categoryA = categoryOrder[a.category] ?? 4; + // const categoryB = categoryOrder[b.category] ?? 4; + // if (categoryA !== categoryB) { + // return categoryA - categoryB; + // } + // + // // Then compare by flight category if available. + // // Assuming that latest_metar.flight_category is a string and "UNKN" needs to come last. + // const fcA = a.latest_metar?.flight_category ?? 'UNKN'; + // const fcB = b.latest_metar?.flight_category ?? 'UNKN'; + // + // if (fcA === 'UNKN' && fcB !== 'UNKN') return 1; + // if (fcB === 'UNKN' && fcA !== 'UNKN') return -1; + // + // // If both flight categories are not "UNKN", do a simple alphabetical comparison. + // // (You may wish to customize this logic based on the actual flight category values.) + // if (fcA < fcB) return -1; + // if (fcA > fcB) return 1; + // return 0; + // }); return ( <> - {sortedAirports.map((airport, index) => ( - + {airports.map((airport, index) => ( +
+ {(showNoMetar || airport.latest_metar != undefined) && ( + + )} +
))} ); diff --git a/ui/src/components/AirportMarker.tsx b/ui/src/components/AirportMarker.tsx index 5047407..8624e3e 100644 --- a/ui/src/components/AirportMarker.tsx +++ b/ui/src/components/AirportMarker.tsx @@ -3,17 +3,20 @@ import { Marker, Popup } from 'react-leaflet'; import L from 'leaflet'; import { useRef } from 'react'; import { getMarkerColor } from '@lib/metar.types.ts'; +import { LayerInfo } from '@/App.tsx'; export default function AirportMarker({ index, airport, - setAirport + setAirport, + selectedLayer, }: { index: number; airport: Airport; setAirport: (airport: Airport) => void; + selectedLayer: LayerInfo; }) { - const icon = createCustomIcon(airport); + const icon = createCustomIcon(airport, selectedLayer); const markerRef = useRef(null); return ( @@ -35,7 +38,7 @@ export default function AirportMarker({ ); } -function createCustomIcon(airport: Airport): L.DivIcon { +function createCustomIcon(airport: Airport, selectedLayer: LayerInfo): L.DivIcon { if (airport.category === AirportCategory.HELIPORT) { return L.divIcon({ html: ` @@ -44,7 +47,7 @@ function createCustomIcon(airport: Airport): L.DivIcon { height: 14px; border-radius: 50%; border: 2px solid black; - background-color: white; + background-color: ${selectedLayer.markerOutline}; display: flex; align-items: center; justify-content: center; @@ -83,7 +86,7 @@ function createCustomIcon(airport: Airport): L.DivIcon { width: 18px; height: 18px; border-radius: 50%; - border: 2px solid #fff; + border: 2px solid ${selectedLayer.markerOutline}; z-index: {info[1]}"> `,