From f90124dabe07ee3a3cde9b7df9670ca7a50ddcfd Mon Sep 17 00:00:00 2001 From: Alan Weinstock Date: Sat, 13 Jun 2026 18:57:35 -0700 Subject: [PATCH] feat(ui): session status widget + SPA API proxy (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SessionWidget.vue: sidebar widget showing Reddit session status (dot color: green/yellow/red by age), age in hours, and a refresh button that triggers the Playwright re-login (~30s, blocking POST) - Add SessionStatus + RefreshResult types and api.reddit.* methods to api.ts (sessionStatus, refreshSession) - Mount widget at bottom of sidebar in App.vue via margin-top:auto - Fix spa_server.py: add reverse proxy for /api/* → FastAPI :8532 (without this, all API calls returned index.html in production mode) - Guard ageText/dotClass computeds against undefined age_hours with typeof check + == null (catches both null and undefined from stale browser-cached HTML responses) Closes: https://git.opensourcesolarpunk.com/Circuit-Forge/magpie/issues/12 --- frontend/src/App.vue | 8 ++ frontend/src/components/SessionWidget.vue | 149 ++++++++++++++++++++++ frontend/src/services/api.ts | 20 +++ scripts/spa_server.py | 86 +++++++++++-- 4 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/SessionWidget.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a17253a..656b7de 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,6 +18,7 @@ 📖 Sub Rules + @@ -58,4 +59,11 @@ + + diff --git a/frontend/src/components/SessionWidget.vue b/frontend/src/components/SessionWidget.vue new file mode 100644 index 0000000..5a92984 --- /dev/null +++ b/frontend/src/components/SessionWidget.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 81fa37e..1850cb4 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -199,6 +199,19 @@ export interface Signal { matched_rules?: SignalRule[] } +export interface SessionStatus { + target: string + valid: boolean + age_hours: number | null + session_file: string +} + +export interface RefreshResult { + target: string + ok: boolean + message: string +} + // ------------------------------------------------------------------ // // Campaigns // ------------------------------------------------------------------ // @@ -314,5 +327,12 @@ export const api = { http.patch(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data), }, + reddit: { + sessionStatus: (target = 'magpie') => + http.get('/reddit/session-status', { params: { target } }).then(r => r.data), + refreshSession: (target = 'magpie') => + http.post('/reddit/refresh-session', null, { params: { target } }).then(r => r.data), + }, + stats: () => http.get('/stats').then(r => r.data), } diff --git a/scripts/spa_server.py b/scripts/spa_server.py index bf7c317..7757f7f 100644 --- a/scripts/spa_server.py +++ b/scripts/spa_server.py @@ -1,44 +1,112 @@ #!/usr/bin/env python3 """ -Static SPA server with index.html fallback. +Static SPA server with index.html fallback and API reverse proxy. Python's built-in http.server returns 404 for any path that isn't a real file, which breaks Vue Router's history-mode navigation. This server falls back to index.html for any path that doesn't match a real file, letting the client-side router handle the route. +Requests to /api/* are forwarded to the FastAPI backend (--api-port). + Usage: - python scripts/spa_server.py --port 8531 --directory frontend/dist + python scripts/spa_server.py --port 8531 --directory frontend/dist --api-port 8532 """ import argparse +import http.client import os from http.server import HTTPServer, SimpleHTTPRequestHandler class SPAHandler(SimpleHTTPRequestHandler): - def do_GET(self): - # Resolve the path to an actual filesystem path inside self.directory. + api_port: int = 8532 + + # ------------------------------------------------------------------ # + # API proxy + # ------------------------------------------------------------------ # + + def _proxy_api(self) -> None: + """Forward this request to the FastAPI backend and relay the response.""" + conn = http.client.HTTPConnection("127.0.0.1", self.api_port, timeout=120) + body_len = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(body_len) if body_len else None + # Strip hop-by-hop headers before forwarding. + skip = {"host", "connection", "transfer-encoding"} + fwd_headers = {k: v for k, v in self.headers.items() if k.lower() not in skip} + try: + conn.request(self.command, self.path, body=body, headers=fwd_headers) + resp = conn.getresponse() + self.send_response(resp.status) + for header, value in resp.getheaders(): + if header.lower() not in ("transfer-encoding", "connection"): + self.send_header(header, value) + self.end_headers() + self.wfile.write(resp.read()) + except OSError as exc: + self.send_error(502, f"API proxy error: {exc}") + finally: + conn.close() + + # ------------------------------------------------------------------ # + # Request dispatch + # ------------------------------------------------------------------ # + + def _is_api(self) -> bool: + path = self.path.split("?", 1)[0] + return path.startswith("/api/") + + def do_GET(self) -> None: + if self._is_api(): + self._proxy_api() + return full = self.translate_path(self.path) - # If the path doesn't resolve to a real file, serve index.html instead. if not os.path.isfile(full): self.path = "/index.html" super().do_GET() - def log_message(self, fmt, *args): - # Suppress per-request noise; errors still surface. + def do_POST(self) -> None: + if self._is_api(): + self._proxy_api() + return + self.send_error(405, "Method Not Allowed") + + def do_PUT(self) -> None: + if self._is_api(): + self._proxy_api() + return + self.send_error(405, "Method Not Allowed") + + def do_PATCH(self) -> None: + if self._is_api(): + self._proxy_api() + return + self.send_error(405, "Method Not Allowed") + + def do_DELETE(self) -> None: + if self._is_api(): + self._proxy_api() + return + self.send_error(405, "Method Not Allowed") + + def log_message(self, fmt: str, *args: object) -> None: if int(args[1]) >= 400: super().log_message(fmt, *args) -def main(): +def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=8531) parser.add_argument("--directory", default="frontend/dist") + parser.add_argument("--api-port", type=int, default=8532) args = parser.parse_args() + SPAHandler.api_port = args.api_port os.chdir(args.directory) server = HTTPServer(("", args.port), SPAHandler) - print(f"Serving {args.directory} on :{args.port} (SPA fallback enabled)") + print( + f"Serving {args.directory} on :{args.port} " + f"(SPA fallback + API proxy → :{args.api_port})" + ) server.serve_forever()