From 14fe712d33d0b27d579c741ac8bf081e0d397462 Mon Sep 17 00:00:00 2001 From: Instinct Agent Date: Thu, 24 Sep 2026 01:09:06 +0100 Subject: [PATCH] feat(app): Logs page with recent events and a level filter (#253) --- docs/troubleshooting.md | 2 +- scripts/windows-entry.js | 4 +- src/app-config.js | 8 ++- src/log-tail.js | 41 +++++++++++++ src/logs-page-handler.js | 112 +++++++++++++++++++++++++++++++++++ src/settings-page-handler.js | 2 +- src/status-page-handler.js | 2 +- test/logs-page.test.js | 72 ++++++++++++++++++++++ 8 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 src/log-tail.js create mode 100644 src/logs-page-handler.js create mode 100644 test/logs-page.test.js diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index afa48984..ad8eab02 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -17,7 +17,7 @@ Then check, in order: ## Windows logs -nowplaying's privacy-safe Windows log lives at `%LOCALAPPDATA%\nowplaying\logs\nowplaying.log`. Right-click the tray icon and choose **Open log folder** to open it in File Explorer. `nowplaying.exe start` records startup, successful start, stop and startup failures (with a short code such as `CONFIG_LOAD_FAILED` or `CONFIG_INVALID`). Provider and updater events are still being connected for v0.2. +nowplaying's privacy-safe Windows log lives at `%LOCALAPPDATA%\nowplaying\logs\nowplaying.log`. Right-click the tray icon and choose **Open log folder** to open it in File Explorer, or open the **Logs** page from the status page to see recent events. `nowplaying.exe start` records startup, successful start, stop and startup failures (with a short code such as `CONFIG_LOAD_FAILED` or `CONFIG_INVALID`). Provider and updater events are still being connected for v0.2. - Each entry records only the time, level, component (`startup`, `provider`, `discord`, `updater`, `tray`), a status and an optional short error code. Titles, usernames, provider URLs and credentials are never written. - When the file reaches 1 MB it rotates to `nowplaying.log.1`, and older files shift up. Three rotated files are kept, so the folder stays around 4 MB at most. diff --git a/scripts/windows-entry.js b/scripts/windows-entry.js index f24daea6..e6f5229b 100644 --- a/scripts/windows-entry.js +++ b/scripts/windows-entry.js @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { createAppLogger } from "../src/app-log.js"; +import { createAppLogger, windowsLogPath } from "../src/app-log.js"; import { StartupError, resolveAppPort, startAppFromConfig } from "../src/app-config.js"; import { createCredentialStore } from "../src/credential-store.js"; import { createHostedCredentials } from "../src/hosted-credentials.js"; @@ -171,7 +171,7 @@ async function startFromWizardConfig(configFile, { safeMode = false } = {}) { let build = null; try { build = JSON.parse(await readFile(resolve("app", "build-info.json"), "utf8")); } catch { build = null; } const packageType = existsSync(resolve("unins000.exe")) ? "installer" : "portable"; - const app = await startAppFromConfig({ configFile, credentialStore, hostedCredentials, port: resolveAppPort(), version: typeof manifest.version === "string" ? manifest.version : null, build, packageType, safeMode }); + const app = await startAppFromConfig({ configFile, credentialStore, hostedCredentials, port: resolveAppPort(), version: typeof manifest.version === "string" ? manifest.version : null, build, packageType, safeMode, logFile: process.env.LOCALAPPDATA ? windowsLogPath({ localAppData: process.env.LOCALAPPDATA }) : null }); console.log(safeMode ? `NowPlaying started in safe mode after repeated failed starts: Discord and hosted uploads are off. Run setup again from the tray to go back to normal. Card: ${app.url}/card.svg` : `NowPlaying is running. Card: ${app.url}/card.svg`); diff --git a/src/app-config.js b/src/app-config.js index 0043fc7c..532df6ef 100644 --- a/src/app-config.js +++ b/src/app-config.js @@ -7,6 +7,8 @@ import { createHttpServer } from "./http-server.js"; import { createAppStatus } from "./app-status.js"; import { createStatusPageHandler } from "./status-page-handler.js"; import { createSettingsPageHandler } from "./settings-page-handler.js"; +import { createLogsPageHandler } from "./logs-page-handler.js"; +import { readLogTail } from "./log-tail.js"; import { createResilientCardResolver } from "./resilient-card.js"; import { createSetupConfig } from "./setup-config.js"; import { createConfigMigrationStore } from "./config-migration-store.js"; @@ -189,7 +191,7 @@ export function resolveAppPort(env = process.env) { return port; } -export async function startAppFromConfig({ configFile, credentialStore, host = "127.0.0.1", port = DEFAULT_APP_PORT, fetchImpl = fetch, discord: discordOptions = {}, version = null, build = null, packageType = null, hostedCredentials, hosted: hostedOptions = {}, safeMode = false } = {}) { +export async function startAppFromConfig({ configFile, credentialStore, host = "127.0.0.1", port = DEFAULT_APP_PORT, fetchImpl = fetch, discord: discordOptions = {}, version = null, build = null, packageType = null, hostedCredentials, hosted: hostedOptions = {}, safeMode = false, logFile = null } = {}) { if (typeof credentialStore?.read !== "function") throw new TypeError("credentialStore.read is required"); const config = await loadAppConfig(configFile); let secret; @@ -234,7 +236,9 @@ export async function startAppFromConfig({ configFile, credentialStore, host = " }, }); const statusHandler = createStatusPageHandler({ status, fallback: createCardHandler({ resolveCard }) }); - const handler = createSettingsPageHandler({ settings, fallback: statusHandler }); + // The Logs page reads the app log (no log file, e.g. a dev checkout: empty). + const logsHandler = createLogsPageHandler({ readEvents: () => readLogTail(logFile), fallback: statusHandler }); + const handler = createSettingsPageHandler({ settings, fallback: logsHandler }); // Saves need the cookie the app's own pages set, so another local program // or web page can't change settings. const server = createHttpServer({ host, port, handler, sessionSecret: randomBytes(32).toString("base64url") }); diff --git a/src/log-tail.js b/src/log-tail.js new file mode 100644 index 00000000..b569f02e --- /dev/null +++ b/src/log-tail.js @@ -0,0 +1,41 @@ +import { open } from "node:fs/promises"; + +// Reads the newest events from the app log for the local Logs page (#253). +// Only the last maxBytes are read, and each line is checked again against the +// log's own shape, so nothing but time, level, component, status and code can +// reach the page even if the file was edited by hand. + +const LEVELS = new Set(["info", "warn", "error"]); +const COMPONENTS = new Set(["startup", "provider", "discord", "updater", "tray"]); +const STATUSES = new Set(["starting", "ok", "idle", "degraded", "failed", "stopped"]); +const CODE = /^[A-Z][A-Z0-9_]{0,47}$/; + +export function parseLogLine(line) { + let value; + try { value = JSON.parse(line); } catch { return null; } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const time = typeof value.time === "string" ? new Date(value.time) : null; + if (!time || !Number.isFinite(time.getTime())) return null; + if (!LEVELS.has(value.level) || !COMPONENTS.has(value.component) || !STATUSES.has(value.status)) return null; + if (value.code !== undefined && (typeof value.code !== "string" || !CODE.test(value.code))) return null; + return Object.freeze({ time: time.toISOString(), level: value.level, component: value.component, status: value.status, ...(value.code ? { code: value.code } : {}) }); +} + +export async function readLogTail(file, { maxLines = 200, maxBytes = 64 * 1024 } = {}) { + if (typeof file !== "string" || !file) return []; + let handle; + try { handle = await open(file, "r"); } + catch (error) { if (error?.code === "ENOENT") return []; throw error; } + try { + const { size } = await handle.stat(); + const length = Math.min(size, maxBytes); + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, size - length); + const lines = buffer.toString("utf8").split("\n"); + // The first line may be cut off when only part of the file was read. + if (length < size) lines.shift(); + return lines.map((line) => line.trim()).filter(Boolean).map(parseLogLine).filter(Boolean).slice(-maxLines); + } finally { + await handle.close(); + } +} diff --git a/src/logs-page-handler.js b/src/logs-page-handler.js new file mode 100644 index 00000000..954ea6a4 --- /dev/null +++ b/src/logs-page-handler.js @@ -0,0 +1,112 @@ +// Local Logs page (#253). Shows the newest app log events and refreshes every +// few seconds. The log only holds time, level, component, status and a short +// code, never server addresses, names, titles or sign-in details. + +const PAGE = ` + +NowPlaying logs +
+ +

Logs

+

Recent events

+

+ +

+ +
TimeLevelPartEvent
+

The log never includes your server address, user name, what you're playing or sign-in details. Use Copy diagnostics on the Status page for bug reports.

+
+
+`; + +const CSS = `.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:0 0 12px}.row label{color:#555} +select{font:inherit;padding:4px 8px;border:1px solid #888;border-radius:6px;background:#fff;color:inherit} +table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:4px 8px;border-bottom:1px solid #e4e4e8;vertical-align:top} +th{color:#555;font-weight:600}td:first-child{white-space:nowrap;font-variant-numeric:tabular-nums} +.hint{color:#555;font-size:13px;margin:12px 0 0} +@media (prefers-color-scheme:dark){select{background:#2c2c31;border-color:#555}th,.row label,.hint{color:#aaa}th,td{border-color:#333}} +`; + +const SCRIPT = `"use strict"; +const RANK = { info: 0, warn: 1, error: 2 }; +const LEVEL_WORDS = { info: ["Info", ""], warn: ["Warning", "warn"], error: ["Error", "bad"] }; +const PARTS = { startup: "App", provider: "Media server", discord: "Discord", updater: "Updates", tray: "Tray" }; +const level = document.getElementById("level"); +const body = document.querySelector("#log tbody"); +let events = []; +function say(text, tone) { const el = document.getElementById("log-result"); el.textContent = text; el.className = tone || ""; } +function cell(row, text, tone) { const td = document.createElement("td"); td.textContent = text; if (tone) td.className = tone; row.appendChild(td); } +function shown() { return events.filter((e) => RANK[e.level] >= RANK[level.value]).slice().reverse(); } +function render() { + const rows = shown(); + body.replaceChildren(); + for (const e of rows) { + const tr = document.createElement("tr"); + const words = LEVEL_WORDS[e.level] || LEVEL_WORDS.info; + cell(tr, new Date(e.time).toLocaleString()); + cell(tr, words[0], words[1]); + cell(tr, PARTS[e.component] || e.component); + cell(tr, e.status + (e.code ? " (" + e.code + ")" : "")); + body.appendChild(tr); + } + document.getElementById("log-empty").hidden = rows.length > 0; +} +async function load() { + try { + const res = await fetch("/api/logs", { cache: "no-store", headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(String(res.status)); + events = (await res.json()).events; + render(); + } catch { + say("Can't reach NowPlaying. It may have been closed.", "bad"); + } +} +level.addEventListener("change", render); +document.getElementById("copy-log").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(shown().map((e) => [e.time, e.level, e.component, e.status, e.code || ""].join(" ").trim()).join("\\n")); + say("Copied.", "ok"); + } catch { + say("Couldn't copy.", "bad"); + } +}); +load(); +setInterval(load, 3000); +`; + +const SAFE_FETCH_SITES = new Set(["same-origin", "none"]); + +export function createLogsPageHandler({ readEvents, fallback } = {}) { + if (typeof readEvents !== "function") throw new TypeError("readEvents: expected a function"); + if (typeof fallback !== "function") throw new TypeError("fallback: expected a handler"); + const assets = { + "/logs": { body: PAGE, type: "text/html; charset=utf-8", page: true }, + "/logs.css": { body: CSS, type: "text/css; charset=utf-8" }, + "/logs.js": { body: SCRIPT, type: "text/javascript; charset=utf-8" }, + }; + return async function handle(request) { + const method = request?.method || "GET"; + const url = new URL(request?.url || "/", "http://localhost"); + const asset = assets[url.pathname]; + if (!asset && url.pathname !== "/api/logs") return fallback(request); + if (method !== "GET" && method !== "HEAD") return response(405, "Method Not Allowed", { Allow: "GET, HEAD" }); + if (asset) { + const result = response(200, method === "HEAD" ? "" : asset.body, { "Content-Type": asset.type, "Cache-Control": "no-store" }); + return asset.page ? { ...result, page: true } : result; + } + // The log is for this app's own page: refuse other sites' requests. + const site = header(request?.headers, "sec-fetch-site"); + if (site !== undefined && !SAFE_FETCH_SITES.has(String(site).toLowerCase())) return response(403, "Forbidden"); + let events; + try { events = await readEvents(); } catch { return json(500, { error: "log_unreadable" }); } + return json(200, { events }, method); + }; +} + +function json(status, value, method = "GET") { return response(status, method === "HEAD" ? "" : JSON.stringify(value), { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); } +function header(headers, name) { + if (!headers) return undefined; + const key = Object.keys(headers).find((value) => value.toLowerCase() === name); + return key ? headers[key] : undefined; +} +function response(status, body, headers = {}) { return Object.freeze({ status, headers: Object.freeze(headers), body }); } diff --git a/src/settings-page-handler.js b/src/settings-page-handler.js index 62094a83..f4919315 100644 --- a/src/settings-page-handler.js +++ b/src/settings-page-handler.js @@ -7,7 +7,7 @@ const PAGE = ` NowPlaying settings
- +

Settings

Discord

diff --git a/src/status-page-handler.js b/src/status-page-handler.js index 70008032..f9f8d7f7 100644 --- a/src/status-page-handler.js +++ b/src/status-page-handler.js @@ -6,7 +6,7 @@ const PAGE = ` NowPlaying status
- +

NowPlaying

Loading status...

Now playing

-

diff --git a/test/logs-page.test.js b/test/logs-page.test.js new file mode 100644 index 00000000..81b83387 --- /dev/null +++ b/test/logs-page.test.js @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { parseLogLine, readLogTail } from "../src/log-tail.js"; +import { createLogsPageHandler } from "../src/logs-page-handler.js"; +import { startAppFromConfig } from "../src/app-config.js"; +import { serializeLogEvent } from "../src/app-log.js"; +import { serializeSetupConfig } from "../src/setup-config.js"; + +const line = (status, extra = {}) => serializeLogEvent({ time: new Date("2026-09-24T00:00:00Z"), level: "info", component: "startup", status, ...extra }); + +async function logFile(text) { + const dir = await mkdtemp(join(tmpdir(), "np-logs-")); + const file = join(dir, "nowplaying.log"); + await writeFile(file, text); + return file; +} + +test("keeps only the log's own fields and drops anything else", () => { + assert.deepEqual({ ...parseLogLine(line("ok", { level: "error", code: "PORT_IN_USE" })) }, { time: "2026-09-24T00:00:00.000Z", level: "error", component: "startup", status: "ok", code: "PORT_IN_USE" }); + const extra = JSON.stringify({ time: "2026-09-24T00:00:00Z", level: "info", component: "startup", status: "ok", serverUrl: "http://10.0.0.2:32400" }); + assert.equal(JSON.stringify(parseLogLine(extra)).includes("10.0.0.2"), false); + for (const bad of ["not json", "[]", "null", JSON.stringify({ time: "x", level: "info", component: "startup", status: "ok" }), JSON.stringify({ time: "2026-09-24T00:00:00Z", level: "debug", component: "startup", status: "ok" }), JSON.stringify({ time: "2026-09-24T00:00:00Z", level: "info", component: "startup", status: "ok", code: "token=abc" })]) { + assert.equal(parseLogLine(bad), null, bad); + } +}); + +test("reads the newest events and skips a cut-off first line", async () => { + const file = await logFile(Array.from({ length: 50 }, () => line("ok")).join("") + line("stopped")); + const all = await readLogTail(file, { maxLines: 10 }); + assert.equal(all.length, 10); + assert.equal(all.at(-1).status, "stopped"); + const part = await readLogTail(file, { maxBytes: 300 }); + assert.ok(part.length >= 1 && part.every((event) => event.component === "startup")); + assert.deepEqual(await readLogTail(join(file, "..", "missing.log")), []); + assert.deepEqual(await readLogTail(null), []); +}); + +test("serves the page and events, GET only, same origin only", async () => { + const h = createLogsPageHandler({ readEvents: async () => [{ time: "t", level: "info", component: "startup", status: "ok" }], fallback: async () => ({ status: 299 }) }); + const page = await h({ url: "/logs" }); + assert.equal(page.page, true); + assert.equal(page.body.toLowerCase().split(" { throw new Error("EACCES C:\\Users\\rowan"); }, fallback: async () => null }); + const failed = await broken({ url: "/api/logs" }); + assert.deepEqual([failed.status, failed.body], [500, '{"error":"log_unreadable"}']); + assert.throws(() => createLogsPageHandler({ fallback: async () => null }), TypeError); + assert.throws(() => createLogsPageHandler({ readEvents: async () => [] }), TypeError); +}); + +test("the running app shows its log on /logs", async () => { + const dir = await mkdtemp(join(tmpdir(), "np-logs-app-")); + const configFile = join(dir, "config.json"); + await writeFile(configFile, serializeSetupConfig({ provider: "jellyfin", serverUrl: "http://127.0.0.1:8096", identity: { id: "u1", displayName: "Rowan" }, credentialStored: true })); + const file = await logFile(line("starting") + line("ok")); + const app = await startAppFromConfig({ configFile, credentialStore: { read: async () => "jf-token" }, port: 0, fetchImpl: async () => Response.json([]), discord: { env: {}, builtInClientId: "" }, logFile: file }); + try { + assert.equal((await fetch(`${app.url}/logs`)).status, 200); + assert.deepEqual((await (await fetch(`${app.url}/api/logs`)).json()).events.map((event) => event.status), ["starting", "ok"]); + } finally { + await app.close(); + } +});