"""Loopback-only status page; Nginx provides HTTPS and the public path.""" from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json from pathlib import Path import threading import time from urllib.parse import parse_qs, urlsplit from . import __version__ from .config import utc_text from .coverage import milliseconds class StatusServer: def __init__(self, config, store): self.config, self.store = config, store self.started = time.monotonic() self.cache = {} self.cache_lock = threading.Lock() folder = Path(__file__).parent / "web" self.assets = { "/": ("text/html; charset=utf-8", (folder / "index.html").read_bytes()), "/assets/status.css": ("text/css; charset=utf-8", (folder / "status.css").read_bytes()), "/assets/status.js": ("text/javascript; charset=utf-8", (folder / "status.js").read_bytes()), } owner = self class Handler(BaseHTTPRequestHandler): server_version = "solar-relay" sys_version = "" def log_message(self, *_): pass def send(self, status, body, kind="application/json; charset=utf-8", *, head=False, headers=None): if not isinstance(body, bytes): body = json.dumps(body, allow_nan=False, separators=(",", ":")).encode() self.send_response(status) self.send_header("Content-Type", kind) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'") for key, value in (headers or {}).items(): self.send_header(key, value) self.end_headers() if not head: self.wfile.write(body) def page(self, head=False): url = urlsplit(self.path) path = url.path prefix = owner.config.http.base_path if prefix and path == prefix: self.send(308, {}, head=head, headers={"Location": prefix + "/"}) return if prefix and path.startswith(prefix + "/"): path = path[len(prefix):] if path in owner.assets: kind, body = owner.assets[path] self.send(200, body, kind, head=head) elif path == "/healthz": self.send(200, {"status": "ok", "service": "solar-relay", "version": __version__}, head=head) elif path == "/api/status": try: values = parse_qs(url.query).get("days", [str(owner.config.http.history_days)]) if len(values) != 1 or not values[0].isdecimal() or not 1 <= int(values[0]) <= 90: self.send(400, {"error": "days must be between 1 and 90"}, head=head) return self.send(200, owner.snapshot(int(values[0])), head=head) except (OSError, ValueError): self.send(503, {"error": "Archive status is temporarily unavailable"}, head=head) else: self.send(404, {"error": "Not found"}, head=head) def do_GET(self): self.page() def do_HEAD(self): self.page(head=True) def do_POST(self): self.send(405, {"error": "Read-only status endpoint"}, headers={"Allow": "GET, HEAD"}) self.server = ThreadingHTTPServer((config.http.bind, config.http.port), Handler) self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.2}, name="solar-status", daemon=True) @property def address(self): return self.server.server_address def start(self): self.thread.start() def close(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=2) def snapshot(self, days): # Repeated page refreshes don't repeatedly scan the historical coverage index. with self.cache_lock: cached = self.cache.get(days) if cached and time.monotonic() - cached[0] < 10: return cached[1] archive = self.store.status() now = datetime.now(timezone.utc) attempt = archive["last_observation"] age = (now.timestamp() * 1000 - milliseconds(attempt)) / 1000 if attempt else None errors = list(archive["last_errors"]) success = self.store.last_success(self.config.collector.source_id) status = "waiting" if age is None else "stale" if age > max(90, self.config.collector.poll_interval_seconds * 3) or age < -90 else "partial" if errors or success != attempt else "ok" body = { "service": "solar-relay", "version": __version__, "generated_at": utc_text(now), "timezone": self.config.collector.timezone, "source_id": self.config.collector.source_id, "status": status, "uptime_seconds": int(time.monotonic() - self.started), "collector": {"last_attempt": attempt, "last_success": success, "age_seconds": round(max(0, age), 1) if age is not None else None, "source_errors": errors, "poll_interval_seconds": self.config.collector.poll_interval_seconds}, "archive": {"samples": archive["samples"], "pending": archive["pending"], "last_upload": archive["last_upload"], "upload_enabled": bool(self.config.upload.url), "format": "parquet", "retention_days": self.config.collector.retention_days}, "history": {"enabled": self.config.history.enabled, "provider": "solarweb_query_api", "boiler_history_available": False}, "days": self.store.coverage(self.config.collector.source_id, self.config.collector.timezone, days, now), } self.cache[days] = time.monotonic(), body return body