Files
solar-relay/solar_relay/status.py
T
franzz ec75ac15b0
Deploy Solar Relay / deploy (push) Failing after 4s
Initial commit
2026-11-01 22:33:32 +01:00

130 lines
6.4 KiB
Python

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