665c941802
The web frontend: the UI itself, the oracle, weather, station and quality renderers, and the serving daemon. The weather and station display modules were each split in two. The dataclasses went to astrape-oracle with the stores that return them; only the rendering stayed here, so the dependency runs dashboard -> oracle -> ems -> gibil with no cycle. Extracted from a single repository where the forecasting engine, the database plumbing, the hardware client, the ingest daemons and the web UI all imported each other freely. Verified that every module imports with gibil resolved from its own installed checkout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
438 lines
12 KiB
Python
438 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from os import environ
|
|
|
|
from astrape_ems.classes.env import EnvLoader
|
|
from astrape_dashboard.classes.sample_data import WeatherSampleData
|
|
from astrape_oracle.classes.weather.store import WeatherStore, WeatherStoreConfigurationError
|
|
from astrape_dashboard.classes.displays.weather import WeatherDisplay
|
|
from astrape_dashboard.classes.displays.station import WeatherStationDisplay
|
|
from astrape_dashboard.classes.displays.oracle import OracleDisplay
|
|
from astrape_dashboard.classes.displays.oracle_quality import OracleQualityDisplay
|
|
|
|
|
|
class WebUI:
|
|
"""Composes Astrape web modules into a small control panel."""
|
|
|
|
def __init__(self) -> None:
|
|
self.weather_display = WeatherDisplay()
|
|
self.weather_station_display = WeatherStationDisplay()
|
|
self.oracle_display = OracleDisplay()
|
|
self.oracle_quality_display = OracleQualityDisplay()
|
|
|
|
def render_page(self, page: str = "oracle") -> str:
|
|
current_page = (
|
|
page
|
|
if page in {"oracle", "weather", "weather_station", "quality"}
|
|
else "oracle"
|
|
)
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Astrape</title>
|
|
<style>
|
|
:root {{
|
|
color-scheme: light;
|
|
--bg: #11151c;
|
|
--surface: #181e27;
|
|
--panel: #1f2733;
|
|
--ink: #edf2f7;
|
|
--muted: #9aa8ba;
|
|
--line: #344052;
|
|
--field: #121821;
|
|
--active: #38bdf8;
|
|
}}
|
|
|
|
* {{
|
|
box-sizing: border-box;
|
|
}}
|
|
|
|
body {{
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
line-height: 1.4;
|
|
}}
|
|
|
|
header {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 24px;
|
|
padding: 22px 28px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: var(--surface);
|
|
}}
|
|
|
|
.brand {{
|
|
display: grid;
|
|
gap: 2px;
|
|
}}
|
|
|
|
nav {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}}
|
|
|
|
nav a {{
|
|
color: var(--muted);
|
|
text-decoration: none;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
padding: 8px 10px;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
nav a:hover {{
|
|
color: var(--ink);
|
|
border-color: var(--line);
|
|
}}
|
|
|
|
nav a.active {{
|
|
color: var(--ink);
|
|
border-color: var(--active);
|
|
background: #102334;
|
|
}}
|
|
|
|
h1, h2, p {{
|
|
margin: 0;
|
|
}}
|
|
|
|
h1 {{
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
h2 {{
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
p {{
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}}
|
|
|
|
main {{
|
|
width: min(1280px, 100%);
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}}
|
|
|
|
.panel {{
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 18px;
|
|
}}
|
|
|
|
.panel + .panel {{
|
|
margin-top: 18px;
|
|
}}
|
|
|
|
.panel-heading {{
|
|
display: grid;
|
|
grid-template-columns: minmax(180px, auto) 1fr;
|
|
gap: 24px;
|
|
margin-bottom: 18px;
|
|
}}
|
|
|
|
.control-row {{
|
|
display: flex;
|
|
align-items: end;
|
|
justify-content: flex-end;
|
|
flex-wrap: wrap;
|
|
gap: 14px;
|
|
}}
|
|
|
|
label {{
|
|
display: grid;
|
|
gap: 6px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
select {{
|
|
min-width: 150px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: var(--field);
|
|
color: var(--ink);
|
|
padding: 8px;
|
|
font: inherit;
|
|
}}
|
|
|
|
.legend-control {{
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
flex-wrap: wrap;
|
|
gap: 10px 12px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}}
|
|
|
|
.legend-title {{
|
|
line-height: 1;
|
|
}}
|
|
|
|
.horizon-options {{
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 8px 12px;
|
|
}}
|
|
|
|
.horizon-option {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: var(--ink);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
line-height: 1.2;
|
|
}}
|
|
|
|
.horizon-option input {{
|
|
width: 14px;
|
|
height: 14px;
|
|
margin: 0;
|
|
}}
|
|
|
|
.horizon-option .horizon-value {{
|
|
width: 54px;
|
|
height: 28px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: var(--field);
|
|
color: var(--ink);
|
|
padding: 4px 6px;
|
|
font: inherit;
|
|
}}
|
|
|
|
.legend-swatch {{
|
|
width: 22px;
|
|
height: 3px;
|
|
border-radius: 999px;
|
|
flex: 0 0 auto;
|
|
}}
|
|
|
|
.truth-swatch {{
|
|
background: #f8fafc;
|
|
}}
|
|
|
|
.chart-shell {{
|
|
position: relative;
|
|
min-height: 420px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
background: var(--panel);
|
|
}}
|
|
|
|
canvas {{
|
|
display: block;
|
|
width: 100%;
|
|
height: 420px;
|
|
}}
|
|
|
|
table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 13px;
|
|
}}
|
|
|
|
th, td {{
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--line);
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}}
|
|
|
|
th:first-child, td:first-child,
|
|
th:nth-child(2), td:nth-child(2) {{
|
|
text-align: left;
|
|
}}
|
|
|
|
th {{
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
.table-shell {{
|
|
overflow-x: auto;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: var(--panel);
|
|
}}
|
|
|
|
.stat-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
}}
|
|
|
|
.stat-card {{
|
|
min-height: 76px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 6px;
|
|
background: var(--panel);
|
|
padding: 12px;
|
|
}}
|
|
|
|
.stat-label {{
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
.stat-value {{
|
|
margin-top: 6px;
|
|
font-size: 24px;
|
|
font-weight: 750;
|
|
line-height: 1.1;
|
|
}}
|
|
|
|
.stat-value span {{
|
|
margin-left: 5px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
}}
|
|
|
|
.metric-good {{
|
|
color: #34d399;
|
|
}}
|
|
|
|
.metric-warn {{
|
|
color: #fbbf24;
|
|
}}
|
|
|
|
.metric-bad {{
|
|
color: #fb7185;
|
|
}}
|
|
|
|
@media (max-width: 760px) {{
|
|
header, .panel-heading, .control-row {{
|
|
display: grid;
|
|
}}
|
|
|
|
.legend-control, .horizon-options {{
|
|
justify-content: flex-start;
|
|
}}
|
|
|
|
main {{
|
|
padding: 14px;
|
|
}}
|
|
|
|
select {{
|
|
width: 100%;
|
|
}}
|
|
|
|
.stat-grid {{
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="brand">
|
|
<h1>Astrape</h1>
|
|
<p>Gibil control panel</p>
|
|
</div>
|
|
{self._nav(current_page)}
|
|
</header>
|
|
<main>
|
|
{self._page_body(current_page)}
|
|
</main>
|
|
<script>
|
|
let astrapeUiVersion = null;
|
|
async function watchUiVersion() {{
|
|
const response = await fetch("/api/ui-version", {{ cache: "no-store" }});
|
|
const payload = await response.json();
|
|
if (astrapeUiVersion === null) {{
|
|
astrapeUiVersion = payload.version;
|
|
return;
|
|
}}
|
|
if (astrapeUiVersion !== payload.version) {{
|
|
window.location.reload();
|
|
}}
|
|
}}
|
|
setInterval(watchUiVersion, 1000);
|
|
watchUiVersion();
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
def _nav(self, current_page: str) -> str:
|
|
pages = [
|
|
("oracle", "/oracle", "Oracle"),
|
|
("weather", "/weather", "Weather"),
|
|
("weather_station", "/weather-station", "Station"),
|
|
("quality", "/quality", "Quality"),
|
|
]
|
|
links = [
|
|
f'<a class="{"active" if key == current_page else ""}" href="{href}">{label}</a>'
|
|
for key, href, label in pages
|
|
]
|
|
return f"<nav>{''.join(links)}</nav>"
|
|
|
|
def _page_body(self, page: str) -> str:
|
|
if page == "weather":
|
|
return self.weather_display.render()
|
|
if page == "weather_station":
|
|
return self.weather_station_display.render()
|
|
if page == "quality":
|
|
return self.oracle_quality_display.render()
|
|
return self.oracle_display.render()
|
|
|
|
def weather_payload(self) -> str:
|
|
EnvLoader().load()
|
|
if environ.get("ASTRAPE_WEB_SAMPLE_DATA") == "1":
|
|
return self.weather_display.data_payload(WeatherSampleData().build())
|
|
|
|
try:
|
|
dataset = WeatherStore.from_env().load_display_dataset()
|
|
except WeatherStoreConfigurationError:
|
|
dataset = None
|
|
|
|
return self.weather_display.data_payload(dataset)
|
|
|
|
def weather_station_payload(self, lookback_hours: float = 168) -> str:
|
|
EnvLoader().load()
|
|
try:
|
|
hours = min(2160, max(1, float(lookback_hours)))
|
|
except (TypeError, ValueError):
|
|
hours = 168
|
|
|
|
try:
|
|
now = datetime.now(timezone.utc)
|
|
dataset = WeatherStore.from_env().load_station_display_dataset(
|
|
start_at=now - timedelta(hours=hours),
|
|
end_at=now,
|
|
)
|
|
except WeatherStoreConfigurationError:
|
|
dataset = None
|
|
|
|
return self.weather_station_display.data_payload(dataset)
|
|
|
|
def oracle_payload(self) -> str:
|
|
EnvLoader().load()
|
|
return self.oracle_display.data_payload()
|
|
|
|
def oracle_quality_payload(self, lookback_hours: float = 168) -> str:
|
|
EnvLoader().load()
|
|
return self.oracle_quality_display.data_payload(lookback_hours=lookback_hours)
|