diff --git a/.env b/.env index 4c75b65..9c5c888 100644 --- a/.env +++ b/.env @@ -1,21 +1,30 @@ RUST_LOG=warn,api=info +HTTPD_DOMAIN=localhost +HTTPD_PROTOCOL=http +HTTPD_HTTP_PORT=8080 +HTTPD_HTTPS_PORT=8443 +HTTPD_MINIO_HOST=host.docker.internal +HTTPD_API_HOST=host.docker.internal +HTTPD_UI_HOST=host.docker.internal + +POSTGRES_HOST=localhost POSTGRES_USER=aviation POSTGRES_PASSWORD=CHANGEME POSTGRES_NAME=aviation POSTGRES_PORT=5432 +REDIS_HOST=localhost REDIS_PORT=6379 +MINIO_HOST=localhost MINIO_ROOT_USER=aviation MINIO_ROOT_PASSWORD=CHANGEME -MINIO_SCHEMA=http +MINIO_BUCKET=aviation +MINIO_PROTOCOL=http MINIO_PORT=9000 MINIO_PORT_INTERNAL=9001 - -HTTPD_PROTOCOL=http -HTTPD_HTTP_PORT=8080 -HTTPD_HTTPS_PORT=8443 +MINIO_BROWSER_REDIRECT_URL=${HTTPD_PROTOCOL}://${HTTPD_DOMAIN}:${HTTPD_HTTP_PORT}/minio/ UI_PROTOCOL=http UI_PORT=3000 @@ -23,25 +32,7 @@ UI_PORT=3000 API_PROTOCOL=http API_PORT=5000 -################################# -# Development (Running Locally) # -################################# -POSTGRES_HOST=localhost -REDIS_HOST=localhost -MINIO_HOST=localhost -HTTPD_HOST=localhost -HTTPD_API_HOST=host.docker.internal -HTTPD_UI_HOST=host.docker.internal -VITE_API_URL=http://localhost:8080/api - -################## -# Running Docker # -################## -#POSTGRES_HOST=aviation-postgres -#REDIS_HOST=aviation-redis -#MINIO_HOST=aviation-redis -#HTTPD_API_HOST=aviation-api -#HTTPD_UI_HOST=aviation-ui +VITE_API_URL=${HTTPD_PROTOCOL}://${HTTPD_DOMAIN}:${HTTPD_HTTP_PORT}/api ENVIRONMENT=development ADMIN_EMAIL=admin@example.com diff --git a/Makefile b/Makefile index 71a1c2b..03e6dc9 100644 --- a/Makefile +++ b/Makefile @@ -93,5 +93,19 @@ docker-up: ## Start the docker container docker-refresh: docker-clean up-backend ## Refresh the database -docker-build: ## Build the docker images - @docker compose --profile backend --profile api build \ No newline at end of file +refresh: docker-refresh + +build: version=$(if $(v),$(v),latest) +build: folder=$(if $(f),$(f),httpd) +build: image=aviation-${folder}:${version} +build: ## Build a specific docker image (`make build f=httpd`) + docker buildx build \ + -f ${folder}/Dockerfile \ + -t ${image} \ + --load \ + --build-arg BUILD_DATE=$$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ + --build-arg BUILD_VERSION=${version} \ + --build-arg VCS_REF=$$(git rev-parse head) \ + ${folder} + +docker-build: build diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index 981b975..e2024e4 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -1,9 +1,6 @@ use crate::error::ApiResult; use redis::{Client as RedisClient, aio::MultiplexedConnection as RedisConnection, RedisResult}; -use s3::{ - Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData, - bucket_ops::CreateBucketResponse, -}; +use s3::{Bucket, Region, creds::Credentials, BucketConfiguration, request::ResponseData}; use serde::{Deserialize, Serialize}; use std::sync::OnceLock; use std::time::Duration; @@ -15,30 +12,33 @@ static REDIS: OnceLock = OnceLock::new(); static BUCKET: OnceLock = OnceLock::new(); pub async fn initialize() -> ApiResult<()> { - let db_user = std::env::var("POSTGRES_USER").unwrap_or("aviation".to_string()); - let db_password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set"); - let db_host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set"); - let db_port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string()); - let db_name = std::env::var("POSTGRES_NAME").unwrap_or("aviation".to_string()); - - let db_url = format!( - "postgres://{}:{}@{}:{}/{}", - &db_user, &db_password, &db_host, &db_port, &db_name - ); - - log::info!( - "Connecting to database at postgres://{}:*****@{}:{}/{}...", - &db_user, - &db_host, - &db_port, - &db_name - ); // Setup Postgres pool connection - let pool = PgPoolOptions::new() - .max_connections(5) - .acquire_timeout(Duration::from_secs(30)) - .connect(&db_url) - .await?; + let pool = { + let user = std::env::var("POSTGRES_USER").unwrap_or("aviation".to_string()); + let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set"); + let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set"); + let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string()); + let name = std::env::var("POSTGRES_NAME").unwrap_or("aviation".to_string()); + + let db_url = format!( + "postgres://{}:{}@{}:{}/{}", + &user, &password, &host, &port, &name + ); + + log::info!( + "Connecting to database at postgres://{}:*****@{}:{}/{}...", + &user, + &host, + &port, + &name + ); + + PgPoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(30)) + .connect(&db_url) + .await? + }; match POOL.set(pool) { Ok(_) => log::info!("Database connection established"), Err(_) => log::warn!("Database pool already initialized"), @@ -49,6 +49,7 @@ pub async fn initialize() -> ApiResult<()> { let host = std::env::var("REDIS_HOST").unwrap_or("localhost".to_string()); let port = std::env::var("REDIS_PORT").unwrap_or("6379".to_string()); let url = format!("redis://{}:{}", host, port); + log::info!("Connecting to redis at {}", &url); RedisClient::open(url).expect("Failed to create redis client") }; match REDIS.set(redis) { @@ -56,33 +57,57 @@ pub async fn initialize() -> ApiResult<()> { Err(_) => log::warn!("Redis client already initialized"), } - let schema = std::env::var("MINIO_SCHEMA").unwrap_or("http".to_string()); - let url = std::env::var("MINIO_HOST").unwrap_or("localhost".to_string()); - let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string()); - let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set"); - let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set"); - let base_url = format!("{}://{}:{}", schema, url, port); + // Setup Bucket connection + let bucket = { + let protocol = std::env::var("MINIO_PROTOCOL").unwrap_or("http".to_string()); + let host = std::env::var("MINIO_HOST").unwrap_or("localhost".to_string()); + let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string()); + let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set"); + let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set"); + let bucket_name = std::env::var("MINIO_BUCKET").unwrap_or("aviation".to_string()); + let url = format!("{}://{}:{}", protocol, host, port); - let region = Region::Custom { - region: "".to_string(), - endpoint: base_url, + let region = Region::Custom { + region: "".to_string(), + endpoint: url.to_string(), + }; + + let credentials = Credentials { + access_key: Some(user), + secret_key: Some(password), + security_token: None, + session_token: None, + expiration: None, + }; + + let bucket = Bucket::new(&bucket_name, region.clone(), credentials.clone())?.with_path_style(); + log::info!("Checking for object in bucket at {}", ®ion.endpoint()); + match bucket.head_object("/").await { + Ok(_) => bucket, + Err(_) => { + log::debug!("Creating '{}' bucket", &bucket_name); + let response = match Bucket::create_with_path_style( + &bucket_name, + region, + credentials, + BucketConfiguration::default(), + ) + .await + { + Ok(response) => response, + Err(err) => { + log::error!("Failed to create bucket '{}': {}", &bucket_name, err); + return Err(err.into()); + } + }; + response.bucket + } + } }; - let credentials = Credentials { - access_key: Some(user), - secret_key: Some(password), - security_token: None, - session_token: None, - expiration: None, - }; - - let bucket = Bucket::new("aviation", region.clone(), credentials.clone()) - .expect("Failed to create S3 Bucket") - .with_path_style(); - match BUCKET.set(*bucket) { - Ok(_) => log::info!("Bucket initialized"), - Err(_) => log::warn!("Bucket client already initialized"), + Ok(_) => log::info!("Bucket connection initialized"), + Err(_) => log::warn!("Bucket connection already initialized"), } // Run migrations @@ -115,42 +140,12 @@ pub async fn redis_async_connection() -> RedisResult { } async fn run_migrations() -> ApiResult<()> { - log::debug!("Running migrations"); + log::debug!("Running database migrations"); let pool = pool(); sqlx::migrate!().run(pool).await?; Ok(()) } -async fn create_bucket() -> ApiResult { - let url = std::env::var("MINIO_URL").unwrap_or("localhost".to_string()); - let port = std::env::var("MINIO_PORT").unwrap_or("9000".to_string()); - let user = std::env::var("MINIO_ROOT_USER").expect("MINIO_ROOT_USER is not set"); - let password = std::env::var("MINIO_ROOT_PASSWORD").expect("MINIO_ROOT_PASSWORD is not set"); - let base_url = format!("http://{}:{}", url, port); - - let region = Region::Custom { - region: "".to_string(), - endpoint: base_url, - }; - - let credentials = Credentials { - access_key: Some(user), - secret_key: Some(password), - security_token: None, - session_token: None, - expiration: None, - }; - let bucket_name = "aviation"; - let response = Bucket::create_with_path_style( - bucket_name, - region, - credentials, - BucketConfiguration::default(), - ) - .await?; - Ok(response) -} - pub async fn upload_file(path: &str, content: &[u8]) -> ApiResult { let response = BUCKET.get().unwrap().put_object(path, content).await?; Ok(response) diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index 1933d14..74a078a 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -935,8 +935,6 @@ impl Metar { // Check for missing metars let missing_icao_list = Self::get_missing_metar_icaos(&metars, icao_list).await; if !missing_icao_list.is_empty() { - log::trace!("Retrieving missing METAR data for {:?}", missing_icao_list); - let mut updated_missing_icao_list: Vec<&str> = Vec::new(); for icao in &missing_icao_list { let result: RedisResult> = conn.get(icao).await; @@ -952,28 +950,34 @@ impl Metar { Err(err) => return Err(err.into()), } } - let mut missing_icao_list = Self::get_remote_metars(&updated_missing_icao_list) - .await - .unwrap_or_else(|err| { - log::warn!("Unable to get remote METAR data; {}", err); - vec![] - }); + 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(&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.station_id, true).await; - missing_metar.insert().await?; + if missing_icao_list.len() > 0 { + // Insert missing METARs + for missing_metar in &missing_icao_list { + let _: RedisResult<()> = conn.set(&missing_metar.station_id, true).await; + missing_metar.insert().await?; + } + metars.append(&mut missing_icao_list) } - metars.append(&mut missing_icao_list) - } - // 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 { - let _: RedisResult<()> = conn.set_ex(&icao, false, 3600).await; + // 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 { + let _: RedisResult<()> = conn.set_ex(&icao, false, 3600).await; + } } } } diff --git a/docker-compose.yml b/docker-compose.yml index 63af6ed..b79c8db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,11 @@ x-env_file: &env name: aviation services: httpd: - build: ./httpd + image: aviation-httpd:latest container_name: aviation-httpd + build: + context: ./httpd + dockerfile: Dockerfile env_file: *env ports: - "${HTTPD_HTTP_PORT:-8080}:80" @@ -61,6 +64,8 @@ services: environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL} + MINIO_BROWSER_LOGIN_ANIMATION: false volumes: - minio:/data ports: @@ -74,13 +79,21 @@ services: restart: unless-stopped api: + image: aviation-api:latest container_name: aviation-api - env_file: *env - ports: - - "${API_PORT:-5000}:5000" build: context: ./api dockerfile: Dockerfile + env_file: *env + environment: + POSTGRES_HOST: aviation-postgres + POSTGRES_PORT: 5432 + REDIS_HOST: aviation-redis + REDIS_PORT: 6379 + MINIO_HOST: aviation-minio + MINIO_PORT: 9000 + ports: + - "${API_PORT:-5000}:5000" depends_on: - postgres - redis @@ -93,15 +106,16 @@ services: restart: unless-stopped ui: + image: aviation-ui:latest container_name: aviation-ui + build: + context: ./ui + dockerfile: Dockerfile env_file: *env environment: - NODE_ENV=${NODE_ENV:-development} ports: - "${UI_PORT:-3000}:3000" - build: - context: ./ui - dockerfile: Dockerfile volumes: - ./ui/src:/app/src - ./ui/public:/app/public diff --git a/httpd/aviation.conf b/httpd/aviation.conf index d159c51..f54f3f3 100644 --- a/httpd/aviation.conf +++ b/httpd/aviation.conf @@ -7,6 +7,9 @@ ProxyPass "/api" "${API_PROTOCOL}://${HTTPD_API_HOST}:${API_PORT}/api" ProxyPassReverse "/api" "${API_PROTOCOL}://${HTTPD_API_HOST}:${API_PORT}/api" + ProxyPass "/minio" "${MINIO_PROTOCOL}://${HTTPD_MINIO_HOST}:${MINIO_PORT_INTERNAL}" + ProxyPassReverse "/minio" "${MINIO_PROTOCOL}://${HTTPD_MINIO_HOST}:${MINIO_PORT_INTERNAL}" + ProxyPass "/" "${UI_PROTOCOL}://${HTTPD_UI_HOST}:${UI_PORT}/" ProxyPassReverse "/" "${UI_PROTOCOL}://${HTTPD_UI_HOST}:${UI_PORT}/" \ No newline at end of file diff --git a/ui/index.html b/ui/index.html index 9be3bce..9119d0b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -6,7 +6,7 @@ - Aviation + Aviation Data
diff --git a/ui/package.json b/ui/package.json index 0406d19..5d664ef 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 3000", + "dev": "vite --host --port 3000", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 04a6d5b..3beaf5a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,7 +23,9 @@ L.Icon.Default.mergeOptions({ shadowUrl: markerShadow }); -const tileLayerUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +const openStreetMapUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +// const rainViewerUrl = 'https://tilecache.rainviewer.com/v2/radar/{time}/256/10/290/391/2/1_1.png' +// https://api.rainviewer.com/public/weather-maps.json const defaultZoom = 6; const defaultCenter: L.LatLngExpression = [38.944444, -77.455833]; @@ -42,14 +44,14 @@ function App() { minZoom={3} maxZoom={19} maxBounds={[ - [-85.06, -180], - [85.06, 180] + [-85.06, -181], + [85.06, 181] ]} scrollWheelZoom={true} zoomControl={false} > - + diff --git a/ui/src/components/AirportLayer.tsx b/ui/src/components/AirportLayer.tsx index 7ec0cb3..33548a1 100644 --- a/ui/src/components/AirportLayer.tsx +++ b/ui/src/components/AirportLayer.tsx @@ -31,8 +31,7 @@ export default function AirportLayer({ setAirport }: { setAirport: (airport: Air getAirports({ bounds: boundsParam, metars: true, - categories: [AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE], - limit: 200 + categories: [AirportCategory.HELIPORT, AirportCategory.SMALL, AirportCategory.MEDIUM, AirportCategory.LARGE] }) .then((response) => { setAirports(response.data); @@ -53,9 +52,39 @@ 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 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 ( <> - {airports.map((airport, index) => { + {sortedAirports.map((airport, index) => { return ; })} diff --git a/ui/src/components/AirportMarker.tsx b/ui/src/components/AirportMarker.tsx index 6fef9b8..d8d7edf 100644 --- a/ui/src/components/AirportMarker.tsx +++ b/ui/src/components/AirportMarker.tsx @@ -1,6 +1,7 @@ -import { Airport } from '@lib/airport.types.ts'; -import { Marker } from 'react-leaflet'; +import { Airport, AirportCategory } from '@lib/airport.types.ts'; +import { Marker, Popup } from 'react-leaflet'; import L from 'leaflet'; +import { useRef } from 'react'; export default function AirportMarker({ index, @@ -11,52 +12,96 @@ export default function AirportMarker({ airport: Airport; setAirport: (airport: Airport) => void; }) { - const markerColor = getMarkerColor(airport); - const icon = createCustomIcon(markerColor); + const icon = createCustomIcon(airport); + const markerRef = useRef(null); + return ( setAirport(airport) + click: () => setAirport(airport), + mouseover: () => markerRef.current?.openPopup(), + mouseout: () => markerRef.current?.closePopup() }} - /> + > + + {airport.icao} - {airport.name} + + ); } -function getMarkerColor(airport: Airport): string { - if (airport.latest_metar) { - switch (airport.latest_metar.flight_category.toUpperCase()) { - case 'IFR': - return '#ff0100'; - case 'LIFR': - return '#7f007f'; - case 'MVFR': - return '#00f'; - case 'VFR': - return '#018000'; - case 'UNKNOWN': - return '#3e3e3e'; - default: - return '#3e3e3e'; - } - } else { - return '#696969'; +function getMarkerColor(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): string { + switch (flightCategory) { + case 'IFR': + return '#ff0100'; + case 'LIFR': + return '#7f007f'; + case 'MVFR': + return '#00f'; + case 'VFR': + return '#018000'; + case 'UNKN': + return '#696969'; } } -function createCustomIcon(color: string): L.DivIcon { - return L.divIcon({ - html: `
`, - className: '', - iconSize: [20, 20], - iconAnchor: [10, 10] - }); +function createCustomIcon(airport: Airport): L.DivIcon { + if (airport.category === AirportCategory.HELIPORT) { + return L.divIcon({ + html: ` +
+ H +
+ `, + className: '', + iconSize: [20, 20], + iconAnchor: [10, 10] + }); + } else { + // Default to a filled circle. + const flightCategory = airport.latest_metar?.flight_category || 'UNKN'; + const color = getMarkerColor(flightCategory); + if (flightCategory == 'UNKN') { + return L.divIcon({ + html: ` +
+
+ `, + className: '', + iconSize: [20, 20], + iconAnchor: [10, 10] + }); + } else { + return L.divIcon({ + html: ` +
+
+ `, + className: '', + iconSize: [20, 20], + iconAnchor: [10, 10] + }); + } + } } diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index 6e97eed..a9f4e0b 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -137,7 +137,7 @@ export function Header() { - FlightLink + Aviation Data {/**/} {/* {navItems}*/} diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts index 6feff05..304afb4 100644 --- a/ui/src/lib/index.ts +++ b/ui/src/lib/index.ts @@ -1,7 +1,3 @@ -// const protocol = process.env.HTTPD_PROTOCOL || 'http'; -// const host = process.env.HTTPD_HOST || 'localhost'; -// const port = process.env.HTTPD_PORT || 8080; -// const baseUrl = `${protocol}://${host}:${port}/api`; const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; export async function getRequest(endpoint: string, params: Record = {}): Promise { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f583b8d..a2919f2 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -11,5 +11,8 @@ export default defineConfig({ '@components': path.resolve(__dirname, './src/components'), '@lib': path.resolve(__dirname, './src/lib'), } + }, + server: { + host: true } }) \ No newline at end of file