diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index 8548795..db7a8c4 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -1,7 +1,5 @@ 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; diff --git a/api/src/metars/metar_check.rs b/api/src/metars/metar_check.rs new file mode 100644 index 0000000..124ea73 --- /dev/null +++ b/api/src/metars/metar_check.rs @@ -0,0 +1,57 @@ +use chrono::{DateTime, Utc}; +use redis::{AsyncCommands, RedisResult}; +use serde::{Deserialize, Serialize}; +use crate::db::redis_async_connection; +use crate::error::ApiResult; + +#[derive(Debug, Serialize, Deserialize)] +pub struct MetarCheck { + pub icao: String, + pub status: bool, + pub updated_at: DateTime, +} + +impl MetarCheck { + pub fn new(icao: String, status: bool) -> Self { + Self { + icao, + status, + updated_at: Utc::now(), + } + } + + pub async fn get(icao: &str) -> Option { + let mut conn = match redis_async_connection().await { + Ok(conn) => conn, + Err(err) => { + log::error!("{}", err); + return None; + } + }; + let result: RedisResult> = conn.get(icao).await; + match result { + Ok(Some(value)) => match serde_json::from_str(&value) { + Ok(result) => Some(result), + Err(err) => { + log::error!("{}", err); + None + } + }, + Ok(None) => None, + Err(err) => { + log::error!("{}", err); + None + } + } + } + + pub async fn insert(&self, seconds: u64) -> ApiResult<()> { + let mut conn = redis_async_connection().await?; + let value = serde_json::to_string(&self)?; + conn + .set_ex::<_, _, ()>(self.icao.as_str(), value, seconds) + .await?; + + Ok(()) + } +} diff --git a/api/src/metars/mod.rs b/api/src/metars/mod.rs index 6fbb137..40caa39 100644 --- a/api/src/metars/mod.rs +++ b/api/src/metars/mod.rs @@ -1,5 +1,7 @@ +mod metar_check; mod model; mod routes; pub use model::*; +pub use metar_check::*; pub use routes::init_routes; diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index b1b2454..2e3996a 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -1,7 +1,7 @@ use crate::error::Error; use crate::{error::ApiResult, db}; use chrono::{DateTime, Datelike, NaiveDate, Utc}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::env; use std::fmt::Display; use std::str::FromStr; @@ -10,6 +10,7 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use crate::airports::{Airport, UpdateAirport}; use crate::db::redis_async_connection; +use crate::metars::MetarCheck; const TABLE_NAME: &str = "metars"; @@ -431,28 +432,19 @@ impl Metar { let visibility: String = if visibility_str.contains("/") { let visibility_parts: Vec<&str> = visibility_str.split("/").collect(); let visibility_left = visibility_parts[0]; - let visibility_right = visibility_parts[1].parse::().unwrap(); + let visibility_right = visibility_parts[1].parse::()?; if visibility_left.starts_with("M") { format!( "M{}", - visibility_left[1..visibility_left.len()] - .parse::() - .unwrap() - / visibility_right + visibility_left[1..visibility_left.len()].parse::()? / visibility_right ) } else if visibility_left.starts_with("P") { format!( "P{}", - visibility_left[1..visibility_left.len()] - .parse::() - .unwrap() - / visibility_right + visibility_left[1..visibility_left.len()].parse::()? / visibility_right ) } else { - format!( - "{}", - visibility_left.parse::().unwrap() / visibility_right - ) + format!("{}", visibility_left.parse::()? / visibility_right) } } else { visibility_str.to_string() @@ -463,22 +455,18 @@ impl Metar { && metar_parts.len() > 1 && visibility_re.is_match(metar_parts[1]) { - let visibility_whole = metar_parts[0].parse::().unwrap(); + let visibility_whole = metar_parts[0].parse::()?; metar_parts.remove(0); let visibility_parts: Vec<&str> = metar_parts[0].split("/").collect(); metar_parts.remove(0); let visibility_left = visibility_parts[0]; - let visibility_right = visibility_parts[1][0..visibility_parts[1].len() - 2] - .parse::() - .unwrap(); + let visibility_right = + visibility_parts[1][0..visibility_parts[1].len() - 2].parse::()?; let visibility = if visibility_left.starts_with("M") { format!( "M{}", visibility_whole - + (visibility_left[1..visibility_left.len()] - .parse::() - .unwrap() - / visibility_right) + + (visibility_left[1..visibility_left.len()].parse::()? / visibility_right) ) } else if visibility_left.starts_with("P") { format!( @@ -909,8 +897,17 @@ impl Metar { 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(); + .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.) @@ -920,8 +917,16 @@ impl Metar { (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)))?; + 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 @@ -929,32 +934,8 @@ impl Metar { Ok(obs_datetime.format("%Y-%m-%dT%H:%M:00Z").to_string()) } - async fn get_missing_metar_icaos( - db_metars: &Vec, - station_icaos: &Vec, - ) -> Vec { - let mut missing_metar_icaos: Vec = vec![]; - let current_time = Utc::now().timestamp(); - let db_metars_set: HashSet<&str> = db_metars.iter().map(|icao| icao.icao.as_str()).collect(); - let station_icaos_set: HashSet<&str> = station_icaos.iter().map(|s| s.as_str()).collect(); - for difference in db_metars_set.symmetric_difference(&station_icaos_set) { - missing_metar_icaos.push(difference.to_string()); - } - let time_offset = env::var("API_METAR_TIME_OFFSET") - .unwrap_or("3000".to_string()) - .parse::() - .unwrap_or(3000); - for metar in db_metars { - if current_time > (metar.observation_time.timestamp() + time_offset) { - log::trace!("{} METAR data is outdated", metar.icao); - missing_metar_icaos.push(metar.icao.to_string()); - } - } - missing_metar_icaos - } - - async fn get_remote_metars(client: &Client, icaos: &[&str]) -> ApiResult> { - let base_url = std::env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set"); + async fn get_remote_metars(client: &Client, icaos: &Vec) -> ApiResult> { + let base_url = env::var("AVIATION_WEATHER_URL").expect("AVIATION_WEATHER_URL must be set"); // Query the remote API for the missing METAR data 10 at a time let icao_chunks = icaos .chunks(10) @@ -962,7 +943,10 @@ impl Metar { .collect::>(); let mut metars: Vec = vec![]; for icao_chunk in icao_chunks { - let url = format!("{}/metar?ids={}&hours=0&order=id,-obs", 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 @@ -1012,100 +996,132 @@ impl Metar { pub async fn find_all( client: &Client, icao_list: &Vec, - force: &bool, + _force: &bool, ) -> ApiResult> { if icao_list.is_empty() { return Ok(Vec::new()); } + let pool = db::pool(); + let metar_rows: Vec = sqlx::query_as::<_, MetarRow>(&format!( + r#" + SELECT DISTINCT ON (icao) * FROM {} + WHERE icao = ANY($1) + ORDER BY icao, observation_time DESC + "#, + TABLE_NAME + )) + .bind(icao_list) + .fetch_all(pool) + .await?; + + let current_time = Utc::now().timestamp(); + let time_offset = env::var("API_METAR_TIME_OFFSET") + .unwrap_or("3000".to_string()) + .parse::() + .unwrap_or(3000); + let short_time_offset: i64 = 300; + + // Setup metars and missing metar structures let mut metars: Vec = vec![]; - if !*force { - let pool = db::pool(); - let metar_rows: Vec = sqlx::query_as::<_, MetarRow>(&format!( - r#" - SELECT DISTINCT ON (icao) * FROM {} - WHERE icao = ANY($1) - ORDER BY icao, observation_time DESC - "#, - TABLE_NAME - )) - .bind(icao_list) - .fetch_all(pool) - .await?; - metars = metar_rows - .into_iter() - .map(|m| Metar::from_db(m).unwrap()) - .collect(); + let mut missing_metar_icaos: Vec = vec![]; + let mut found_metar_icaos: HashSet = HashSet::new(); + let mut requested_icaos: HashSet = HashSet::from_iter(icao_list.clone()); + + // Iterate over returned database metars + for metar_row in metar_rows { + let icao = metar_row.icao.clone(); + // Remove icao from requested icaos + requested_icaos.remove(&icao); + + // Handle outdated metars + if current_time > (metar_row.observation_time.timestamp() + time_offset) { + let refresh_seconds = match MetarCheck::get(&icao).await { + Some(c) => current_time - c.updated_at.timestamp(), + None => short_time_offset, + }; + // If the metar was cached more than short_time_offset minutes ago, refresh it + if refresh_seconds >= short_time_offset { + log::trace!("{} METAR data is outdated, refreshing now", &icao); + missing_metar_icaos.push(icao); + } + // Otherwise return outdated data and wait + else { + log::trace!( + "{} METAR data is outdated; refreshing in {} seconds", + &icao, + short_time_offset - refresh_seconds + ); + metars.push(Metar::from_db(metar_row)?) + } + } + // Otherwise add the metar to the vector + else { + found_metar_icaos.insert(icao.clone()); + let metar_check = MetarCheck::new(icao, true); + metar_check.insert(time_offset as u64).await?; + metars.push(Metar::from_db(metar_row)?); + } } - let mut conn = redis_async_connection().await?; - // Check for missing metars - let missing_icao_list = Self::get_missing_metar_icaos(&metars, icao_list).await; - if !missing_icao_list.is_empty() { - let mut updated_missing_icao_list: Vec<&str> = Vec::new(); - for icao in &missing_icao_list { - if *force { - updated_missing_icao_list.push(icao); - } else { - let result: RedisResult> = conn.get(icao).await; - match result { - Ok(Some(value)) => { - if value { - updated_missing_icao_list.push(icao); - } - } - Ok(None) => updated_missing_icao_list.push(icao), - Err(err) => { - log::error!("{}", err); - return Err(err.into()); - } + // Add all metars that were not in the returned database metars + for icao in &requested_icaos { + match MetarCheck::get(icao).await { + Some(c) => { + if current_time > (c.updated_at.timestamp() + short_time_offset) { + missing_metar_icaos.push(icao.to_string()); } } + None => { + missing_metar_icaos.push(icao.to_string()); + } } - if !updated_missing_icao_list.is_empty() { - log::trace!( - "Retrieving missing METAR data for {:?}", - updated_missing_icao_list - ); - let mut missing_icao_list = Self::get_remote_metars(client, &updated_missing_icao_list) - .await - .unwrap_or_else(|err| { - log::warn!("Unable to get remote METAR data; {}", err); - vec![] - }); + } - if missing_icao_list.len() > 0 { - // Insert missing METARs - for missing_metar in &missing_icao_list { - let _: RedisResult<()> = conn.set(&missing_metar.icao, true).await; - missing_metar.insert().await?; - } - metars.append(&mut missing_icao_list) - } + if !missing_metar_icaos.is_empty() { + log::trace!( + "Retrieving missing METAR data for {:?}", + missing_metar_icaos + ); + let mut remote_metars = Self::get_remote_metars(client, &missing_metar_icaos) + .await + .unwrap_or_else(|err| { + log::warn!("Unable to get remote METAR data; {}", err); + vec![] + }); - // Invalidate the still missing icaos - let still_missing_icao_list = - Self::get_missing_metar_icaos(&missing_icao_list, icao_list).await; - if !still_missing_icao_list.is_empty() { - for icao in still_missing_icao_list { - // Skip values if they've been set to true in the past - let result: RedisResult> = conn.get(&icao).await; - if let Ok(Some(v)) = result { - if v { - continue; - } - } - let _: RedisResult<()> = conn.set_ex(&icao, false, 3600).await; - } + if remote_metars.len() > 0 { + // Insert missing METARs + for remote_metar in &remote_metars { + found_metar_icaos.insert(remote_metar.icao.to_string()); + let metar_check = MetarCheck::new(remote_metar.icao.clone(), true); + metar_check.insert(time_offset as u64).await?; + remote_metar.insert().await?; } + metars.append(&mut remote_metars) } + + // Update still missing metars + let mut still_missing_metar_icaos: Vec = vec![]; + for difference in found_metar_icaos.symmetric_difference(&requested_icaos) { + still_missing_metar_icaos.push(difference.to_string()); + let metar_check = MetarCheck::new(difference.to_string(), false); + metar_check.insert(short_time_offset as u64).await? + } + // if !still_missing_metar_icaos.is_empty() { + // log::trace!("Still missing METAR data from {:?}", still_missing_metar_icaos); + // } } Ok(metars) } pub async fn insert(&self) -> ApiResult<()> { - log::trace!("Inserting metar {} with observation time {}", self.icao, self.observation_time); + log::trace!( + "Inserting metar {} with observation time {}", + self.icao, + self.observation_time + ); let metar: MetarRow = self.to_db()?; metar.insert().await?; Ok(()) @@ -1137,13 +1153,12 @@ mod tests { ); // 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 - ) - }); + 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) => {} } diff --git a/bruno/Metars/Find Metars.bru b/bruno/Metars/Find Metars.bru index 97a132e..f0e0c7a 100644 --- a/bruno/Metars/Find Metars.bru +++ b/bruno/Metars/Find Metars.bru @@ -5,11 +5,11 @@ meta { } get { - url: {{API_URL}}/metars?icaos=KHEF,KJYO + url: {{API_URL}}/metars?icaos=KIAD body: none auth: none } params:query { - icaos: KHEF,KJYO + icaos: KIAD } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1e41568..f373ebb 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -36,8 +36,8 @@ export interface LayerInfo { 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'}, -] + { 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; @@ -137,7 +137,7 @@ function App() { function BaseLayerChangeHandler() { useMapEvents({ baselayerchange: (e) => { - const index = layerMap.findIndex(layer => layer.name === e.name); + const index = layerMap.findIndex((layer) => layer.name === e.name); setSelectedLayerIndex(`${index}`); Cookies.set('selectedLayer', `${index}`, { expires: 7 }); setSelectedLayer(layerMap[index]); diff --git a/ui/src/components/AirportDrawer.tsx b/ui/src/components/AirportDrawer.tsx index cbd6391..5c7c715 100644 --- a/ui/src/components/AirportDrawer.tsx +++ b/ui/src/components/AirportDrawer.tsx @@ -1,7 +1,7 @@ import { Badge, Box, Divider, Drawer, Group, Tabs, TabsList, Text, Tooltip } from '@mantine/core'; import { Airport, AirportCategory } from '@lib/airport.types.ts'; import { getMarkerColor, Metar } from '@lib/metar.types.ts'; -import { forwardRef, useEffect, useState } from 'react'; +import { forwardRef, ReactNode, useEffect, useState } from 'react'; import { getMetars } from '@lib/metar.ts'; import { useMediaQuery } from '@mantine/hooks'; @@ -56,7 +56,9 @@ export default function AirportDrawer({ > - {airport.name} + + {airport.name} + @@ -72,7 +74,7 @@ export default function AirportDrawer({ padding: '10px' }} > - + {metar.flight_category} {/*{metar.flight_category}*/} @@ -86,10 +88,12 @@ export default function AirportDrawer({ Info Weather - - {airport.latest_metar && ( - - )} + + + + + + @@ -98,53 +102,62 @@ export default function AirportDrawer({ ); } -function AirportInfo({ airport }: { airport: Airport }) { - return (
-
-
- - ICAO - - - {airport.icao} - -
-
- - IATA - - - {airport.iata} - -
-
- - Local - - - {airport.local} - -
-
- - Category - - - {airportCategoryToText(airport.category)} - -
+function AirportInfoSlot({ title, value, units }: { title: string; value: string | number; units?: string }) { + return ( +
+ + {title} + + + {value} + {units} +
- -
); + ); } -function WeatherInfo({ metar }: { metar: Metar }) { - return <>{metar.raw_text} +function AirportInfoRow({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function AirportInfo({ airport }: { airport: Airport }) { + return ( +
+ + + + + + + + + + + Zoom To + + +
+ ); +} + +function WeatherInfo({ metar }: { metar?: Metar }) { + if (metar) { + return <>{metar.raw_text}; + } else { + return <>No METAR observation available; + } } function airportCategoryToText(category: AirportCategory): string { @@ -168,20 +181,26 @@ function airportCategoryToText(category: AirportCategory): string { } } -const TimeSince = forwardRef( - ({ date }, ref) => { - const inputDate = new Date(date); - // @ts-expect-error doing arithmetic with dates - const seconds = Math.floor((new Date() - inputDate) / 1000); +const TimeSince = forwardRef(({ date }, ref) => { + 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, userSelect: 'none' }}>{content}; - } + 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, userSelect: 'none' }}> + {content} + + ); } -); +}); diff --git a/ui/src/components/AirportMarker.tsx b/ui/src/components/AirportMarker.tsx index 6ce6f64..15c705b 100644 --- a/ui/src/components/AirportMarker.tsx +++ b/ui/src/components/AirportMarker.tsx @@ -9,7 +9,7 @@ export default function AirportMarker({ index, airport, setAirport, - selectedLayer, + selectedLayer }: { index: number; airport: Airport; @@ -28,7 +28,7 @@ export default function AirportMarker({ eventHandlers={{ click: () => setAirport(airport), mouseover: () => markerRef.current?.openPopup(), - mouseout: () => markerRef.current?.closePopup(), + mouseout: () => markerRef.current?.closePopup() }} > diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index b65d02c..3f3fe74 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -155,9 +155,7 @@ export function Header() { )} )} - {isMobile && ( - - )} + {isMobile && }