feat(ui): session status widget + SPA API proxy (#12)

- 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: #12
This commit is contained in:
Alan Weinstock 2026-06-13 18:57:35 -07:00
parent 15779e3114
commit f90124dabe
4 changed files with 254 additions and 9 deletions

View file

@ -18,6 +18,7 @@
<router-link class="nav-link" to="/subs" active-class="active">
<span>📖</span> Sub Rules
</router-link>
<SessionWidget class="sidebar-session" />
</nav>
<!-- Main -->
@ -58,4 +59,11 @@
<script setup lang="ts">
import ToastList from './components/ToastList.vue'
import StatsBar from './components/StatsBar.vue'
import SessionWidget from './components/SessionWidget.vue'
</script>
<style scoped>
.sidebar-session {
margin-top: auto;
}
</style>

View file

@ -0,0 +1,149 @@
<template>
<div class="session-widget" :title="tooltipText">
<span class="session-label">Reddit</span>
<span class="session-dot" :class="dotClass" aria-hidden="true" />
<span class="session-age">{{ ageText }}</span>
<button
class="session-refresh"
:disabled="refreshing"
:aria-label="refreshing ? 'Refreshing session…' : 'Refresh Reddit session'"
@click="doRefresh"
>
<span :class="{ spinning: refreshing }"></span>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api } from '../services/api'
import type { SessionStatus } from '../services/api'
import { useToast } from '../composables/useToast'
const { success, error } = useToast()
const status = ref<SessionStatus | null>(null)
const refreshing = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const dotClass = computed(() => {
if (!status.value || typeof status.value !== 'object') return 'dot--unknown'
if (!status.value.valid) return 'dot--bad'
const age = status.value.age_hours ?? 0
if (age >= 8) return 'dot--warn'
return 'dot--ok'
})
const ageText = computed(() => {
if (!status.value || typeof status.value !== 'object') return '—'
const age = status.value.age_hours
if (age == null) return 'none'
return `${age.toFixed(1)}h`
})
const tooltipText = computed(() => {
if (!status.value) return 'Session status unknown'
if (!status.value.valid) return 'Reddit session expired — click ↻ to refresh'
return `Session valid · ${ageText.value} old · ${status.value.session_file}`
})
async function fetchStatus() {
try {
status.value = await api.reddit.sessionStatus()
} catch {
// non-critical widget just shows unknown state
}
}
async function doRefresh() {
refreshing.value = true
try {
await api.reddit.refreshSession()
await fetchStatus()
success('Reddit session refreshed')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Refresh failed'
error(`Session refresh failed: ${msg}`)
} finally {
refreshing.value = false
}
}
onMounted(() => {
fetchStatus()
pollTimer = setInterval(fetchStatus, 60_000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<style scoped>
.session-widget {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
margin: 8px 8px 0;
border-radius: 8px;
background: var(--color-bg-lift);
border: 1px solid var(--color-border);
font-size: 0.75rem;
color: var(--color-text-muted);
user-select: none;
}
.session-label {
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.session-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition: background 0.3s;
}
.dot--ok { background: var(--color-success, #3ecf8e); }
.dot--warn { background: var(--color-warning, #f0b429); }
.dot--bad { background: var(--color-danger, #f26b6b); }
.dot--unknown { background: var(--color-text-dim); }
.session-age {
flex: 1;
font-family: var(--font-mono);
font-size: 0.7rem;
}
.session-refresh {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
padding: 0 2px;
line-height: 1;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.session-refresh:hover:not(:disabled) {
color: var(--color-text);
background: var(--color-bg-card);
}
.session-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinning {
display: inline-block;
animation: spin 1s linear infinite;
}
</style>

View file

@ -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<Signal>(`/signals/${id}/status`, { status, notes: notes ?? null }).then(r => r.data),
},
reddit: {
sessionStatus: (target = 'magpie') =>
http.get<SessionStatus>('/reddit/session-status', { params: { target } }).then(r => r.data),
refreshSession: (target = 'magpie') =>
http.post<RefreshResult>('/reddit/refresh-session', null, { params: { target } }).then(r => r.data),
},
stats: () => http.get('/stats').then(r => r.data),
}

View file

@ -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()