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:
parent
15779e3114
commit
f90124dabe
4 changed files with 254 additions and 9 deletions
|
|
@ -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>
|
||||
|
|
|
|||
149
frontend/src/components/SessionWidget.vue
Normal file
149
frontend/src/components/SessionWidget.vue
Normal 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>
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue