Fixing loading in docker environment, updated markers

This commit is contained in:
2025-04-11 13:36:59 -04:00
parent 98887d7fef
commit ecd01bd49c
14 changed files with 282 additions and 186 deletions

39
.env
View File

@@ -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

View File

@@ -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
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

View File

@@ -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<RedisClient> = OnceLock::new();
static BUCKET: OnceLock<Bucket> = 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 {}", &region.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<RedisConnection> {
}
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<CreateBucketResponse> {
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<ResponseData> {
let response = BUCKET.get().unwrap().put_object(path, content).await?;
Ok(response)

View File

@@ -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<Option<bool>> = 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;
}
}
}
}

View File

@@ -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

View File

@@ -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}/"
</VirtualHost>

View File

@@ -6,7 +6,7 @@
<link rel="stylesheet" href="styles/global.css">
<link rel="stylesheet" href="styles/leaflet.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aviation</title>
<title>Aviation Data</title>
</head>
<body>
<div id="root"></div>

View File

@@ -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",

View File

@@ -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}
>
<ZoomControl position={'bottomright'} />
<TileLayer url={tileLayerUrl} />
<TileLayer url={openStreetMapUrl} />
<AirportLayer setAirport={setAirport} />
</MapContainer>
</div>

View File

@@ -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 <AirportMarker airport={airport} index={index} setAirport={setAirport} />;
})}
</>

View File

@@ -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<L.Marker>(null);
return (
<Marker
key={index}
ref={markerRef}
position={[airport.latitude, airport.longitude]}
icon={icon}
eventHandlers={{
click: () => setAirport(airport)
click: () => setAirport(airport),
mouseover: () => markerRef.current?.openPopup(),
mouseout: () => markerRef.current?.closePopup()
}}
/>
>
<Popup closeButton={false}>
{airport.icao} - {airport.name}
</Popup>
</Marker>
);
}
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: `<div style="
background-color: ${color};
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #fff;
"></div>`,
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
function createCustomIcon(airport: Airport): L.DivIcon {
if (airport.category === AirportCategory.HELIPORT) {
return L.divIcon({
html: `
<div style="
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid black;
background-color: white;
display: flex;
align-items: center;
justify-content: center;">
<span style="color: black; font-size: 8px; font-weight: bold;">H</span>
</div>
`,
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: `
<div style="
background-color: ${color};
width: 10px;
height: 10px;
border-radius: 50%;">
</div>
`,
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
} else {
return L.divIcon({
html: `
<div style="
background-color: ${color};
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #fff;">
</div>
`,
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
}
}
}

View File

@@ -137,7 +137,7 @@ export function Header() {
<Group align='center' gap='xs'>
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
<Avatar src='/logo.svg' alt='logo' />
<Text>FlightLink</Text>
<Text>Aviation Data</Text>
</Group>
{/*<Group gap={5} visibleFrom='xs' className={classes.navGroup}>*/}
{/* {navItems}*/}

View File

@@ -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<string, any> = {}): Promise<Response> {

View File

@@ -11,5 +11,8 @@ export default defineConfig({
'@components': path.resolve(__dirname, './src/components'),
'@lib': path.resolve(__dirname, './src/lib'),
}
},
server: {
host: true
}
})