Cleanup, refactored, various changes
This commit is contained in:
11
README.md
11
README.md
@@ -8,4 +8,13 @@
|
|||||||
1. Copy `.env.TEMPLATE` to `.env`
|
1. Copy `.env.TEMPLATE` to `.env`
|
||||||
2. Generate JWT RS256 (RSA Signature with SHA-256) Private/Public keys with `make generate`
|
2. Generate JWT RS256 (RSA Signature with SHA-256) Private/Public keys with `make generate`
|
||||||
3. Build the service and ui images with `make build`
|
3. Build the service and ui images with `make build`
|
||||||
4. Run the application with `make up`
|
4. Run the application with `make up`
|
||||||
|
|
||||||
|
## Decoding METARS
|
||||||
|
The following resources were used to help decode METARS.
|
||||||
|
- [Metar Decode Key PDF](https://www.weather.gov/media/wrh/mesowest/metar_decode_key.pdf)
|
||||||
|
- [Metar Decode (NPS EDU)](https://met.nps.edu/~bcreasey/mr3222/files/helpful/DecodeMETAR-TAF.html)
|
||||||
|
- [Weather Phenomena](http://www.moratech.com/aviation/metar-class/metar-pg9-ww.html)
|
||||||
|
|
||||||
|
## OpenMapTiles
|
||||||
|
[Generate Vector Tiles](https://openmaptiles.org/docs/generate/generate-openmaptiles/)
|
||||||
@@ -34817,6 +34817,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"icao": "KIAH",
|
"icao": "KIAH",
|
||||||
|
"full_name": "George Bush Intercontinental Airport",
|
||||||
"category": "large_airport",
|
"category": "large_airport",
|
||||||
"point":
|
"point":
|
||||||
{
|
{
|
||||||
@@ -36735,23 +36736,6 @@
|
|||||||
"iata_code": "",
|
"iata_code": "",
|
||||||
"local_code": "K19"
|
"local_code": "K19"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"icao": "KK20",
|
|
||||||
"category": "small_airport",
|
|
||||||
"full_name": "Wendell H Ford Airport",
|
|
||||||
"point":
|
|
||||||
{
|
|
||||||
"y": 37.384838,
|
|
||||||
"x": -83.259662
|
|
||||||
},
|
|
||||||
"elevation_ft": 1256,
|
|
||||||
"iso_country": "US",
|
|
||||||
"iso_region": "US-KY",
|
|
||||||
"municipality": "Chavies",
|
|
||||||
"gps_code": "",
|
|
||||||
"iata_code": "",
|
|
||||||
"local_code": "CPF"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"icao": "KK22",
|
"icao": "KK22",
|
||||||
"category": "small_airport",
|
"category": "small_airport",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ pub struct Airport {
|
|||||||
pub icao: String,
|
pub icao: String,
|
||||||
pub category: String,
|
pub category: String,
|
||||||
pub full_name: String,
|
pub full_name: String,
|
||||||
pub point: Point,
|
|
||||||
pub elevation_ft: Option<i32>,
|
pub elevation_ft: Option<i32>,
|
||||||
pub iso_country: String,
|
pub iso_country: String,
|
||||||
pub iso_region: String,
|
pub iso_region: String,
|
||||||
@@ -22,6 +21,7 @@ pub struct Airport {
|
|||||||
pub gps_code: String,
|
pub gps_code: String,
|
||||||
pub iata_code: String,
|
pub iata_code: String,
|
||||||
pub local_code: String,
|
pub local_code: String,
|
||||||
|
pub point: Point,
|
||||||
pub tower: Option<bool>,
|
pub tower: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@ impl Into<QueryAirport> for Airport {
|
|||||||
icao: self.icao.clone(),
|
icao: self.icao.clone(),
|
||||||
category: self.category.clone(),
|
category: self.category.clone(),
|
||||||
full_name: self.full_name.clone(),
|
full_name: self.full_name.clone(),
|
||||||
point: self.point.clone(),
|
|
||||||
iso_country: self.iso_country.clone(),
|
iso_country: self.iso_country.clone(),
|
||||||
iso_region: self.iso_region.clone(),
|
iso_region: self.iso_region.clone(),
|
||||||
municipality: self.municipality.clone(),
|
municipality: self.municipality.clone(),
|
||||||
gps_code: self.gps_code.clone(),
|
gps_code: self.gps_code.clone(),
|
||||||
iata_code: self.iata_code.clone(),
|
iata_code: self.iata_code.clone(),
|
||||||
local_code: self.local_code.clone(),
|
local_code: self.local_code.clone(),
|
||||||
|
point: self.point.clone(),
|
||||||
data: match serde_json::to_value(&self) {
|
data: match serde_json::to_value(&self) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{error_handler::ServiceError, airports::QueryAirport};
|
use crate::{error_handler::ServiceError, airports::{QueryAirport, Airport}};
|
||||||
use diesel::{r2d2::ConnectionManager, PgConnection};
|
use diesel::{r2d2::ConnectionManager, PgConnection};
|
||||||
use redis::{Client as RedisClient, aio::Connection as RedisConnection};
|
use redis::{Client as RedisClient, aio::Connection as RedisConnection};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -63,10 +63,10 @@ pub fn import_data() -> i32 {
|
|||||||
let path = "airport-codes.json";
|
let path = "airport-codes.json";
|
||||||
debug!("Importing data from {}", path);
|
debug!("Importing data from {}", path);
|
||||||
let contents: String = std::fs::read_to_string(path).expect("Failed to read file");
|
let contents: String = std::fs::read_to_string(path).expect("Failed to read file");
|
||||||
let airports: Vec<QueryAirport> = serde_json::from_str(&contents).expect("JSON was not well formed.");
|
let airports: Vec<Airport> = serde_json::from_str(&contents).expect("JSON was not well formed.");
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for airport in airports {
|
for airport in airports {
|
||||||
match QueryAirport::insert(airport) {
|
match QueryAirport::insert(airport.into()) {
|
||||||
Ok(_) => count += 1,
|
Ok(_) => count += 1,
|
||||||
Err(err) => error!("Error inserting airport; {}", err)
|
Err(err) => error!("Error inserting airport; {}", err)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ impl Metar {
|
|||||||
if metar_parts[0] == "AUTO" {
|
if metar_parts[0] == "AUTO" {
|
||||||
metar.quality_control_flags.auto = Some(true);
|
metar.quality_control_flags.auto = Some(true);
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
} else if metar_parts[0] == "COR" {
|
}
|
||||||
|
if metar_parts[0] == "COR" {
|
||||||
metar.quality_control_flags.corrected = Some(true);
|
metar.quality_control_flags.corrected = Some(true);
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
}
|
}
|
||||||
@@ -270,7 +271,10 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Weather Phenomena
|
// Weather Phenomena
|
||||||
let wx_re = regex::Regex::new(r"^(?:[+-]|VC|MI|PR|BC|DR|BL|SH|TS|FZ)?(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)$").unwrap();
|
let wx_intensity = "(?:[+-]|VC)?";
|
||||||
|
let wx_descriptor = "(?:MI|PR|BC|DR|BL|SH|TS|FZ)?";
|
||||||
|
let wx_precipitation = "(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)?";
|
||||||
|
let wx_re = regex::Regex::new(&format!(r"^{}{}{}$", wx_intensity, wx_descriptor, wx_precipitation)).unwrap();
|
||||||
while wx_re.is_match(metar_parts[0]) {
|
while wx_re.is_match(metar_parts[0]) {
|
||||||
metar.weather_phenomena.push(metar_parts[0].to_string());
|
metar.weather_phenomena.push(metar_parts[0].to_string());
|
||||||
metar_parts.remove(0);
|
metar_parts.remove(0);
|
||||||
@@ -396,7 +400,7 @@ impl Metar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Flight Category
|
// Flight Category
|
||||||
if metar.visibility_statute_mi.is_none() || metar.sky_condition.is_empty() {
|
if metar.visibility_statute_mi.is_none() && metar.sky_condition.is_empty() {
|
||||||
metar.flight_category = FlightCategory::UNKN;
|
metar.flight_category = FlightCategory::UNKN;
|
||||||
} else {
|
} else {
|
||||||
let visibility = match &metar.visibility_statute_mi {
|
let visibility = match &metar.visibility_statute_mi {
|
||||||
@@ -407,7 +411,7 @@ impl Metar {
|
|||||||
v.parse::<f64>().unwrap()
|
v.parse::<f64>().unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => 0.0
|
None => 5.0 // Assume VFR if no visibility is present
|
||||||
};
|
};
|
||||||
let ceiling = match metar.sky_condition.first() {
|
let ceiling = match metar.sky_condition.first() {
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ pub fn update_airports() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug!("METAR update complete");
|
debug!("METAR update complete");
|
||||||
// Sleep until the observation time is 1 hour old
|
// Sleep until the earliest observation time is 1 hour old
|
||||||
|
// Bounded by 1 and 3600 seconds
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
let sleep_time = now - (observation_time + (3600));
|
let sleep_time = std::cmp::min(std::cmp::max(1, now - (observation_time + (3600))), 3600);
|
||||||
debug!("Next update in {} seconds", sleep_time);
|
debug!("Next update in {} seconds", sleep_time);
|
||||||
sleep(Duration::from_secs(sleep_time as u64)).await;
|
sleep(Duration::from_secs(sleep_time as u64)).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export interface Airport {
|
|||||||
category: AirportCategory;
|
category: AirportCategory;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
elevation_ft: number;
|
elevation_ft: number;
|
||||||
continent: string;
|
|
||||||
iso_country: string;
|
iso_country: string;
|
||||||
iso_region: string;
|
iso_region: string;
|
||||||
municipality: string;
|
municipality: string;
|
||||||
@@ -58,7 +57,7 @@ export interface Airport {
|
|||||||
y: number;
|
y: number;
|
||||||
srid: number;
|
srid: number;
|
||||||
};
|
};
|
||||||
metar?: Metar;
|
latest_metar?: Metar;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetAirportResponse {
|
export interface GetAirportResponse {
|
||||||
|
|||||||
@@ -5,30 +5,39 @@ export interface SkyCondition {
|
|||||||
|
|
||||||
export interface QualityControlFlags {
|
export interface QualityControlFlags {
|
||||||
auto: boolean;
|
auto: boolean;
|
||||||
auto_station: boolean;
|
auto_station_without_precipitation: boolean;
|
||||||
|
auto_station_with_precipication: boolean;
|
||||||
|
maintenance_indicator_on: boolean;
|
||||||
|
corrected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunwayVisualRange {
|
||||||
|
runway: string;
|
||||||
|
visibility_ft: string;
|
||||||
|
variable_visibility_high_ft: string;
|
||||||
|
variable_visibility_low_ft: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Metar {
|
export interface Metar {
|
||||||
raw_text: string;
|
raw_text: string;
|
||||||
station_id: string;
|
station_id: string;
|
||||||
observation_time: string;
|
observation_time: string;
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
temp_c: number;
|
temp_c: number;
|
||||||
dewpoint_c: number;
|
dewpoint_c: number;
|
||||||
wind_dir_degrees: string;
|
wind_dir_degrees: string;
|
||||||
wind_speed_kt: number;
|
wind_speed_kt: number;
|
||||||
|
wind_gust_kt: number;
|
||||||
|
variable_wind_dir_degrees: string;
|
||||||
visibility_statute_mi: string;
|
visibility_statute_mi: string;
|
||||||
|
runway_visual_range: RunwayVisualRange[];
|
||||||
altim_in_hg: number;
|
altim_in_hg: number;
|
||||||
sea_level_pressure_mb: number;
|
sea_level_pressure_mb: number;
|
||||||
quality_control_flags: QualityControlFlags;
|
quality_control_flags: QualityControlFlags;
|
||||||
wx_string: string;
|
weather_phenomena: string[];
|
||||||
sky_condition: SkyCondition[];
|
sky_condition: SkyCondition[];
|
||||||
flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN';
|
flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN';
|
||||||
three_hr_pressure_tendency_mb: number;
|
three_hr_pressure_tendency_mb: number;
|
||||||
metar_type: string;
|
|
||||||
maxT_c: number;
|
maxT_c: number;
|
||||||
minT_c: number;
|
minT_c: number;
|
||||||
precip_in: number;
|
precip_in: number;
|
||||||
elevation_m: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export default function AirportTablePanel({ setAirport }: { setAirport: (airport
|
|||||||
<Table.Td>{airport.icao}</Table.Td>
|
<Table.Td>{airport.icao}</Table.Td>
|
||||||
<Table.Td>{airport.full_name}</Table.Td>
|
<Table.Td>{airport.full_name}</Table.Td>
|
||||||
<Table.Td>{airportCategoryToText(airport.category)}</Table.Td>
|
<Table.Td>{airportCategoryToText(airport.category)}</Table.Td>
|
||||||
<Table.Td>{airport.continent}</Table.Td>
|
|
||||||
<Table.Td>{airport.iso_country}</Table.Td>
|
<Table.Td>{airport.iso_country}</Table.Td>
|
||||||
<Table.Td>{airport.iso_region}</Table.Td>
|
<Table.Td>{airport.iso_region}</Table.Td>
|
||||||
<Table.Td>{airport.municipality}</Table.Td>
|
<Table.Td>{airport.municipality}</Table.Td>
|
||||||
@@ -64,7 +63,6 @@ export default function AirportTablePanel({ setAirport }: { setAirport: (airport
|
|||||||
<Table.Th>ICAO</Table.Th>
|
<Table.Th>ICAO</Table.Th>
|
||||||
<Table.Th>Full Name</Table.Th>
|
<Table.Th>Full Name</Table.Th>
|
||||||
<Table.Th>Category</Table.Th>
|
<Table.Th>Category</Table.Th>
|
||||||
<Table.Th>Continent</Table.Th>
|
|
||||||
<Table.Th>ISO Country</Table.Th>
|
<Table.Th>ISO Country</Table.Th>
|
||||||
<Table.Th>ISO Region</Table.Th>
|
<Table.Th>ISO Region</Table.Th>
|
||||||
<Table.Th>Municipality</Table.Th>
|
<Table.Th>Municipality</Table.Th>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createAirport } from "@/api/airport";
|
|||||||
import { Airport, AirportCategory } from "@/api/airport.types";
|
import { Airport, AirportCategory } from "@/api/airport.types";
|
||||||
import { Card, TextInput, Select, Group, Flex, Space, Button } from "@mantine/core";
|
import { Card, TextInput, Select, Group, Flex, Space, Button } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function CreateAirportPanel() {
|
export default function CreateAirportPanel() {
|
||||||
const form = useForm<Airport>({
|
const form = useForm<Airport>({
|
||||||
@@ -11,7 +10,6 @@ export default function CreateAirportPanel() {
|
|||||||
category: AirportCategory.SMALL,
|
category: AirportCategory.SMALL,
|
||||||
full_name: '',
|
full_name: '',
|
||||||
elevation_ft: 0,
|
elevation_ft: 0,
|
||||||
continent: '',
|
|
||||||
iso_country: '',
|
iso_country: '',
|
||||||
iso_region: '',
|
iso_region: '',
|
||||||
municipality: '',
|
municipality: '',
|
||||||
@@ -64,12 +62,6 @@ export default function CreateAirportPanel() {
|
|||||||
{...form.getInputProps('elevation_ft')}
|
{...form.getInputProps('elevation_ft')}
|
||||||
/>
|
/>
|
||||||
<Group>
|
<Group>
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
label='Continent'
|
|
||||||
placeholder='NA'
|
|
||||||
{...form.getInputProps('continent')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
required
|
required
|
||||||
label='ISO Country'
|
label='ISO Country'
|
||||||
@@ -82,13 +74,13 @@ export default function CreateAirportPanel() {
|
|||||||
placeholder='US-VA'
|
placeholder='US-VA'
|
||||||
{...form.getInputProps('iso_region')}
|
{...form.getInputProps('iso_region')}
|
||||||
/>
|
/>
|
||||||
|
<TextInput
|
||||||
|
required
|
||||||
|
label='Municipality'
|
||||||
|
placeholder='Manassas'
|
||||||
|
{...form.getInputProps('municipality')}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
label='Municipality'
|
|
||||||
placeholder='Manassas'
|
|
||||||
{...form.getInputProps('municipality')}
|
|
||||||
/>
|
|
||||||
<Group>
|
<Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export default function UpdateAirportModal({ airport, setAirport }: { airport: A
|
|||||||
category: airport?.category || AirportCategory.SMALL,
|
category: airport?.category || AirportCategory.SMALL,
|
||||||
full_name: airport?.full_name || '',
|
full_name: airport?.full_name || '',
|
||||||
elevation_ft: airport?.elevation_ft || 0,
|
elevation_ft: airport?.elevation_ft || 0,
|
||||||
continent: airport?.continent || '',
|
|
||||||
iso_country: airport?.iso_country || '',
|
iso_country: airport?.iso_country || '',
|
||||||
iso_region: airport?.iso_region || '',
|
iso_region: airport?.iso_region || '',
|
||||||
municipality: airport?.municipality || '',
|
municipality: airport?.municipality || '',
|
||||||
@@ -73,12 +72,6 @@ export default function UpdateAirportModal({ airport, setAirport }: { airport: A
|
|||||||
{...form.getInputProps('elevation_ft')}
|
{...form.getInputProps('elevation_ft')}
|
||||||
/>
|
/>
|
||||||
<Group>
|
<Group>
|
||||||
<TextInput
|
|
||||||
required
|
|
||||||
label='Continent'
|
|
||||||
placeholder='NA'
|
|
||||||
{...form.getInputProps('continent')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
required
|
required
|
||||||
label='ISO Country'
|
label='ISO Country'
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function MapTiles() {
|
|||||||
metars.forEach((metar) => {
|
metars.forEach((metar) => {
|
||||||
airportData.forEach((airport) => {
|
airportData.forEach((airport) => {
|
||||||
if (metar.station_id == airport.icao) {
|
if (metar.station_id == airport.icao) {
|
||||||
airport.metar = metar;
|
airport.latest_metar = metar;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -82,16 +82,18 @@ export default function MapTiles() {
|
|||||||
className: 'metar-marker-icon'
|
className: 'metar-marker-icon'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (airport.metar?.flight_category == 'VFR') {
|
if (airport.latest_metar?.flight_category == 'VFR') {
|
||||||
return innerIcon({ tag: 'V', color: 'green' });
|
return innerIcon({ tag: 'V', color: 'green' });
|
||||||
} else if (airport.metar?.flight_category == 'MVFR') {
|
} else if (airport.latest_metar?.flight_category == 'MVFR') {
|
||||||
return innerIcon({ tag: 'M', color: 'blue' });
|
return innerIcon({ tag: 'M', color: 'blue' });
|
||||||
} else if (airport.metar?.flight_category == 'IFR') {
|
} else if (airport.latest_metar?.flight_category == 'IFR') {
|
||||||
return innerIcon({ tag: 'I', color: 'red' });
|
return innerIcon({ tag: 'I', color: 'red' });
|
||||||
} else if (airport.metar?.flight_category == 'LIFR') {
|
} else if (airport.latest_metar?.flight_category == 'LIFR') {
|
||||||
return innerIcon({ tag: 'L', color: 'purple' });
|
return innerIcon({ tag: 'L', color: 'purple' });
|
||||||
} else {
|
} else if (airport.latest_metar?.flight_category == 'UNKN') {
|
||||||
return innerIcon({ tag: 'U', color: 'black', size: 'xs' });
|
return innerIcon({ tag: 'U', color: 'black', size: 'xs' });
|
||||||
|
} else {
|
||||||
|
return innerIcon({tag: ' ', color: 'black', size: 'xs' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps
|
|||||||
</span>
|
</span>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<Divider style={{ paddingTop: '0.1em' }} />
|
<Divider style={{ paddingTop: '0.1em' }} />
|
||||||
{airport.metar && <MetarInfo metar={airport.metar} />}
|
{airport.latest_metar && <MetarInfo metar={airport.latest_metar} />}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
@@ -164,8 +164,8 @@ function MetarInfo({ metar }: { metar: Metar }) {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col className='gutter-row' span={12}>
|
<Grid.Col className='gutter-row' span={12}>
|
||||||
<Grid style={{ paddingTop: '1em', paddingBottom: '1em' }} gutter={48}>
|
<Grid style={{ paddingTop: '1em', paddingBottom: '1em' }} gutter={48}>
|
||||||
{metar.wx_string &&
|
{metar.weather_phenomena &&
|
||||||
metar.wx_string.split(' ').map((wx) => (
|
metar.weather_phenomena.map((wx) => (
|
||||||
<Grid.Col span={1}>
|
<Grid.Col span={1}>
|
||||||
<MetarIcon wx={wx} />
|
<MetarIcon wx={wx} />
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|||||||
Reference in New Issue
Block a user