1 parent
36180dffd7
commit
ec75ac15b0
30 files changed
+3042
-1
No files matched your search
@@ -0,0 +1,129 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user