From aeb6958aabec92a8722f1b9d97ce0d1031e68917 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Wed, 23 Sep 2026 03:29:12 +0000 Subject: [PATCH] Add Diagnostics section to settings: version, browser engine, log view For triaging webview rendering issues without devtools: settings panel now shows app version, the webview user agent, and the application log (pre-wrap, 80ch, scrollable, refreshable). New endpoints /api/version, /api/log; client-side JS errors are captured globally and posted to /api/client-log (client-errors.log in the log dir). --- frontend/src/api.ts | 27 +++++- frontend/src/components/Header.vue | 127 ++++++++++++++++++++++++++++- frontend/src/main.ts | 33 ++++++++ mediahive/server.py | 62 +++++++++++++- 4 files changed, 241 insertions(+), 8 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b155b7c..8989918 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -190,9 +190,7 @@ export async function fetchResumePositions(): Promise = {} - for (const [key, watch] of Object.entries( - rawEpisodes as Record, - )) { + for (const [key, watch] of Object.entries(rawEpisodes as Record)) { if (!watch || typeof watch !== "object") continue const w = watch as { pos?: unknown; done?: unknown } if (typeof w.pos !== "number" || !Number.isFinite(w.pos)) continue @@ -438,3 +436,26 @@ export async function pickFolderAndAddRoot(): Promise { const folder: string | null = await api.pick_folder() return folder } + +/** + * Fetch the installed MediaHive version. + */ +export async function getVersion(): Promise { + const response = await fetch("/api/version") + if (!response.ok) { + throw new Error(`Failed to load version: ${response.statusText}`) + } + const data = await response.json() + return data.version || "dev" +} + +/** + * Fetch the tail of the application log. + */ +export async function getLog(): Promise { + const response = await fetch("/api/log") + if (!response.ok) { + throw new Error(`Failed to load log: ${response.statusText}`) + } + return response.text() +} diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 44e434e..830bc70 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -29,7 +29,9 @@ @@ -208,7 +210,9 @@

Preferred Format

-

Preferred format when multiple versions are available.

+

+ Preferred format when multiple versions are available. +

@@ -295,6 +299,30 @@
+ +
+

Diagnostics

+

+ Version info and application log for troubleshooting. +

+ +
+
+ App version + {{ appVersion || "…" }} +
+
+ Browser engine + {{ browserEngine }} +
+
+ +
+ Application log + +
+
{{ appLog }}
+
@@ -306,7 +334,7 @@ import { ref, watch, computed, onMounted, onUnmounted } from "vue" import { useRouter, useRoute } from "vue-router" import { navAttrs } from "../composables/useKeyboardNavigation" import logoUrl from "../assets/mediahive.webp" -import { replaceRoots, pickFolderAndAddRoot, fetchPlayers } from "../api" +import { replaceRoots, pickFolderAndAddRoot, fetchPlayers, getVersion, getLog } from "../api" import type { PlayerInfo } from "../api" import HexKeyboard from "./HexKeyboard.vue" import { @@ -409,6 +437,30 @@ async function refreshPlayers() { } } +const appVersion = ref("") +const appLog = ref("") +const browserEngine = navigator.userAgent +let diagnosticsFetched = false + +async function refreshLog() { + try { + appLog.value = await getLog() + } catch (e) { + console.error("Failed to fetch log:", e) + appLog.value = "Failed to load log." + } +} + +async function refreshDiagnostics() { + try { + appVersion.value = await getVersion() + } catch (e) { + console.error("Failed to fetch version:", e) + appVersion.value = "unknown" + } + await refreshLog() +} + async function removeRoot(rootId: string) { const filtered = roots.value.filter((r) => r.root_id !== rootId) const newRoots = Object.fromEntries(filtered.map((r) => [r.root_id, r.path])) @@ -438,6 +490,10 @@ async function addRoot() { watch(showSettings, (visible) => { if (visible) { void refreshPlayers() + if (!diagnosticsFetched) { + diagnosticsFetched = true + void refreshDiagnostics() + } } }) @@ -868,4 +924,69 @@ onUnmounted(() => { font-size: 0.8rem; color: #22c55e; } + +.diag-rows { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.diag-row { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; +} + +.diag-label { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.diag-value { + font-size: 0.85rem; + color: var(--text-primary); + word-break: break-all; +} + +.diag-log-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.diag-refresh-btn { + padding: 4px 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: var(--text-primary); + font-size: 0.8rem; + cursor: pointer; + transition: background 0.2s; +} + +.diag-refresh-btn:hover { + background: rgba(255, 255, 255, 0.15); +} + +.diag-log { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-all; + width: 80ch; + max-width: 100%; + max-height: 320px; + overflow-y: auto; + margin: 0; + padding: 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + color: var(--text-secondary); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 55bb80b..10d20d3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -6,6 +6,38 @@ import { installKeyboardNavigation } from "./composables/useKeyboardNavigation" import { installGamepadNavigation } from "./composables/useGamepadNavigation" import { installInputModalityTracking } from "./composables/useInputModality" +function postClientError(payload: { + message: string + stack: string | null + source: string | null +}) { + fetch("/api/client-log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(() => {}) +} + +function installErrorCapture() { + window.addEventListener("error", (event) => { + const source = + event.filename != null ? `${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}` : null + postClientError({ + message: event.message || String(event.error ?? "Unknown error"), + stack: event.error?.stack ?? null, + source, + }) + }) + window.addEventListener("unhandledrejection", (event) => { + const reason = event.reason + postClientError({ + message: reason instanceof Error ? reason.message : `Unhandled rejection: ${String(reason)}`, + stack: reason instanceof Error ? (reason.stack ?? null) : null, + source: "unhandledrejection", + }) + }) +} + function installReloadShortcut() { document.addEventListener( "keydown", @@ -27,6 +59,7 @@ installInputModalityTracking() installKeyboardNavigation() installGamepadNavigation() installReloadShortcut() +installErrorCapture() // Unregister any legacy service workers — MediaHive no longer uses a PWA/SW. if ("serviceWorker" in navigator) { diff --git a/mediahive/server.py b/mediahive/server.py index 00af587..ba13cfd 100644 --- a/mediahive/server.py +++ b/mediahive/server.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio import ctypes +import importlib.metadata import json import logging import mimetypes @@ -30,11 +31,16 @@ import aiofiles import msgspec from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, Response, StreamingResponse +from fastapi.responses import ( + FileResponse, + PlainTextResponse, + Response, + StreamingResponse, +) from fastapi_vue import Frontend from mediahive.__main__ import DEVMODE -from mediahive.config import load_config +from mediahive.config import load_config, log_dir from mediahive.hivescan.images import close_image_client from mediahive.hivescan.scanner import RootScanner from mediahive.hivescan.tmdb_client import close_http_client @@ -1073,6 +1079,58 @@ async def health_check(): return {"status": "ok"} +@app.get("/api/version") +async def get_version(): + """Return the installed MediaHive package version.""" + try: + version = importlib.metadata.version("mediahive") + except importlib.metadata.PackageNotFoundError: + version = "dev" + return {"version": version} + + +@app.get("/api/log") +async def get_log(): + """Return the tail of the application log file (last ~64 KB).""" + path = log_dir() / "mediahive.log" + if not path.exists(): + return PlainTextResponse("") + try: + with path.open("rb") as f: + f.seek(0, 2) + size = f.tell() + f.seek(max(0, size - 64 * 1024)) + data = f.read() + return PlainTextResponse(data.decode("utf-8", errors="replace")) + except OSError: + return PlainTextResponse("") + + +@app.post("/api/client-log", status_code=204) +async def post_client_log(request: Request): + """Append a client-side (webview) error report to client-errors.log.""" + try: + payload = msgspec.json.decode(await request.body()) + except msgspec.DecodeError: + payload = {} + message = str(payload.get("message") or "") + stack = payload.get("stack") + source = payload.get("source") + try: + dirpath = log_dir() + dirpath.mkdir(parents=True, exist_ok=True) + with (dirpath / "client-errors.log").open("a", encoding="utf-8") as f: + timestamp = datetime.now().isoformat(timespec="seconds") + f.write(f"[{timestamp}] {message}\n") + if source: + f.write(f" source: {source}\n") + if stack: + f.write(f" stack: {stack}\n") + except OSError: + pass + return Response(status_code=204) + + @app.get("/api/config") async def get_config(): """Return current server configuration."""