From 8bf450cf405401aa6468987e4ff4000e2b94d6d1 Mon Sep 17 00:00:00 2001 From: kh0pper <93635715+kh0pper@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:36:30 -0600 Subject: [PATCH 1/2] fix(frigate): live camera pictures in the panel, a working Web UI link, and one login at a time (1.2.0) Seen on a phone: the panel showed no picture, its Web UI tab iframed http://localhost:8971 (the phone itself, blocked by CSP), and parallel logins tripped Frigate's 1/second login limit, which the client reported as wrong credentials. The Overview now shows each camera's latest picture through the gateway's authenticated route, refreshed every 2 s while visible; the Web UI tab links to FRIGATE_UI_URL (e.g. a Tailscale Serve address) in a new tab and never renders the loopback address; both login paths share one in-flight login and retry a 429. config.yml.example turns detection on (Frigate 0.18 needs it). Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp --- bundles/frigate/.env.example | 6 + bundles/frigate/config.yml.example | 2 + bundles/frigate/manifest.json | 3 +- bundles/frigate/panel/frigate.js | 239 +++++++++++++- bundles/frigate/panel/routes.js | 77 +++-- bundles/frigate/server/frigate-api.js | 89 ++++-- docs/developers/port-allocation.md | 1 + docs/es/integrations/frigate.md | 12 +- docs/integrations/frigate.md | 13 +- registry/add-ons.json | 7 +- tests/frigate-panel.test.js | 434 ++++++++++++++++++++++++++ tests/widgets-addons.test.js | 2 +- 12 files changed, 816 insertions(+), 69 deletions(-) create mode 100644 tests/frigate-panel.test.js diff --git a/bundles/frigate/.env.example b/bundles/frigate/.env.example index 290ced9d6..7f32aa010 100644 --- a/bundles/frigate/.env.example +++ b/bundles/frigate/.env.example @@ -8,6 +8,12 @@ # acceptable only for tailnet/loopback deployments. FRIGATE_URL=http://localhost:8971 +# The address YOUR BROWSER uses to open Frigate's own web UI (the panel's Web UI tab +# links to it in a new tab). FRIGATE_URL above is a loopback address only the server +# can reach, so a phone cannot use it. Set this to a Tailscale Serve HTTPS URL, e.g. +# https://frigate.example.ts.net — leave it blank to hide the Web UI link. +# FRIGATE_UI_URL= + # Admin credentials for JWT login (POST /api/login → Bearer token). # Omit if you have set `auth.enabled: false` in config.yml (local-only / proxy setups). # FRIGATE_USER=admin diff --git a/bundles/frigate/config.yml.example b/bundles/frigate/config.yml.example index 9f1a8f87b..9c1e20b61 100644 --- a/bundles/frigate/config.yml.example +++ b/bundles/frigate/config.yml.example @@ -60,6 +60,8 @@ cameras: - detect - record detect: + # Frigate 0.18 needs this, or detection stays off for the camera. + enabled: true width: 1280 height: 720 fps: 5 diff --git a/bundles/frigate/manifest.json b/bundles/frigate/manifest.json index 726df4ae5..010eff3b0 100644 --- a/bundles/frigate/manifest.json +++ b/bundles/frigate/manifest.json @@ -1,7 +1,7 @@ { "id": "frigate", "name": "Frigate NVR", - "version": "1.1.1", + "version": "1.2.0", "description": "Self-hosted NVR with AI object detection — cameras, events, clips. RTSP/ONVIF cameras, local CPU detection, optional Coral TPU.", "type": "bundle", "author": "Crow", @@ -19,6 +19,7 @@ "requires": { "env": [], "min_ram_mb": 2048, "recommended_ram_mb": 4096, "min_disk_mb": 10000, "recommended_disk_mb": 50000 }, "env_vars": [ { "name": "FRIGATE_URL", "description": "Frigate base URL (authenticated UI+API on :8971). Use http:// when config.yml has tls.enabled=false (recommended on loopback).", "default": "http://localhost:8971", "required": true }, + { "name": "FRIGATE_UI_URL", "description": "Address your browser uses to open Frigate's own web UI, e.g. a Tailscale Serve HTTPS URL. Leave blank to hide the Web UI link.", "required": false }, { "name": "FRIGATE_USER", "description": "Admin user for JWT login (POST /api/login). Omit with FRIGATE_PASSWORD if auth.enabled=false in config.yml.", "required": false }, { "name": "FRIGATE_PASSWORD", "description": "Password for FRIGATE_USER.", "required": false, "secret": true }, { "name": "FRIGATE_CONFIG_PATH", "description": "Host path for Frigate config directory.", "default": "~/.crow/data/frigate/config", "required": false }, diff --git a/bundles/frigate/panel/frigate.js b/bundles/frigate/panel/frigate.js index 8c2b96d50..3036c17ad 100644 --- a/bundles/frigate/panel/frigate.js +++ b/bundles/frigate/panel/frigate.js @@ -1,18 +1,32 @@ /** - * Crow's Nest Panel — Frigate: cameras, recent events with thumbnails, system stats, Web UI iframe. + * Crow's Nest Panel — Frigate: cameras with live stills, recent events, system stats, + * and a link to Frigate's own web UI. * * Bundle-compatible version: uses dynamic imports with appRoot instead of * static ESM imports, so this panel works both from the repo and when * installed to ~/.crow/panels/. * * Overview tab: REST-driven via /api/frigate/* routes (bundles/frigate/panel/routes.js). - * Web UI tab: iframe Frigate's own UI. Frigate sets its own JWT cookie inside the - * iframe from the :8971 origin — same first-party pattern as the Jellyfin panel. + * The camera list is read once here, server-side, so every card carries a still in the + * first paint; the browser then refreshes each still every two seconds through the same + * gateway route, so Frigate's JWT never leaves the gateway process. The gateway's still + * route builds its upstream path itself and forwards no query parameters, so a + * downscale hint such as ?h=360 would be dropped — the panel asks for the still as-is + * and appends only a cache-busting `t`. * - * Client-side JS uses DOM construction (createElement + textContent + appendChild) - * so there is no innerHTML assignment with dynamic content. + * Web UI tab: a link to FRIGATE_UI_URL that opens in a new tab. Frigate's own UI is not + * embedded: its address is a loopback one only this server can reach (a phone resolves + * "localhost" to itself), and a cross-origin frame is also blocked by the dashboard's + * frame-ancestors policy. + * + * Client-side JS uses DOM construction (createElement + textContent + appendChild) so + * there is no innerHTML assignment with dynamic content. It is emitted inside a + * template literal — never put a backtick in it. */ +/** How long the Overview waits for Frigate's camera list before drawing the empty shell. */ +const CAMERA_LIST_BUDGET_MS = 2000; + export default { id: "frigate", name: "Frigate", @@ -41,14 +55,9 @@ export default { let body = ""; if (tab === "webui") { - const frigateUrl = process.env.FRIGATE_URL || "http://localhost:8971"; - body = ` -
- -
- `; + body = renderWebUI(browserUiUrl(), escapeHtml); } else { - body = renderOverview(); + body = renderOverview(await readCameras(appRoot), escapeHtml); } const content = ` @@ -65,13 +74,122 @@ export default { }, }; -function renderOverview() { +/** + * The address this browser uses for Frigate's own UI (manifest env var FRIGATE_UI_URL). + * Only http(s) is accepted: a link is rendered from it, and any other scheme would let a + * stored value run script in the dashboard. A blank or unusable value hides the link. + */ +function browserUiUrl() { + const raw = String(process.env.FRIGATE_UI_URL || "").trim(); + if (!raw) return null; + try { + const scheme = new URL(raw).protocol; + if (scheme !== "http:" && scheme !== "https:") return null; + } catch { + return null; + } + return raw; +} + +/** The gateway's still route for one camera (panel/routes.js). The name is URL-encoded. */ +function stillSrc(name) { + return `/api/frigate/${encodeURIComponent(String(name == null ? "" : name))}/latest.jpg`; +} + +/** + * This bundle's own Frigate client. A panel file runs from /panels/.js when + * installed and from bundles//panel/ in a checkout, so its own code is loaded by path: + * this instance's installed copy first, then the app's own copy. + */ +async function loadBundleClient(appRoot) { + const { join } = await import("node:path"); + const { homedir } = await import("node:os"); + const { pathToFileURL } = await import("node:url"); + + const home = process.env.CROW_HOME || join(homedir(), ".crow"); + const candidates = [join(home, "bundles", "frigate", "server", "frigate-api.js")]; + if (appRoot) candidates.push(join(appRoot, "bundles", "frigate", "server", "frigate-api.js")); + + for (const path of candidates) { + try { + return await import(pathToFileURL(path).href); + } catch { + // try the next copy + } + } + return null; +} + +/** Frigate's /api/config camera map, mapped the way panel/routes.js maps it. */ +function camerasFromConfig(config) { + const map = config && typeof config === "object" ? config.cameras : null; + if (!map || typeof map !== "object") return []; + return Object.entries(map).map(([name, cam]) => ({ + name, + enabled: !cam || cam.enabled !== false, + detect_enabled: !cam || cam.detect?.enabled !== false, + width: cam?.detect?.width || null, + height: cam?.detect?.height || null, + tracked_objects: Array.isArray(cam?.objects?.track) ? cam.objects.track : [], + })); +} + +/** + * The camera list for the first paint. Returns null when it could not be read inside the + * budget (Frigate down, no credentials yet): the browser then fills the grid from + * /api/frigate/cameras, which reports the real error on the page. + */ +async function readCameras(appRoot) { + const client = await loadBundleClient(appRoot); + if (!client || typeof client.frigateFetch !== "function") return null; + let timer = null; + try { + const config = await Promise.race([ + Promise.resolve().then(() => client.frigateFetch("/api/config")), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("camera list timed out")), CAMERA_LIST_BUDGET_MS); + try { timer?.unref?.(); } catch { /* a test's timer need not support unref */ } + }), + ]); + return camerasFromConfig(config); + } catch { + return null; + } finally { + if (timer) clearTimeout(timer); + } +} + +/** One card per camera: the current still, its name, and what Frigate is detecting on it. */ +function cameraCards(cameras, escapeHtml) { + if (!cameras || cameras.length === 0) { + return `
No cameras configured. Edit config.yml to add RTSP sources.
`; + } + return cameras.map((camera) => { + const name = String(camera.name == null ? "" : camera.name); + const resolution = camera.width && camera.height ? `${camera.width}×${camera.height}` : "?"; + const tracked = (camera.tracked_objects || []).join(", ") || "none"; + const detect = camera.detect_enabled ? "detect" : "detect off"; + return ` +
+ ${escapeHtml(name)} +
${escapeHtml(name)}
+
${detect} ${escapeHtml(resolution)}
+
tracking: ${escapeHtml(tracked)}
+
`; + }).join(""); +} + +function renderOverview(cameras, escapeHtml) { + const cards = cameras === null + ? `
Loading cameras…
` + : cameraCards(cameras, escapeHtml); + return `

Cameras

-
Loading cameras…
+ ${cards}
@@ -88,6 +206,32 @@ function renderOverview() { `; } +/** + * The Web UI tab. Frigate's UI lives on a loopback address only the server can reach, so + * this page never embeds it and never prints that address: with FRIGATE_UI_URL set, it + * offers a link to the address the browser uses, and otherwise says how to set one. + */ +function renderWebUI(uiUrl, escapeHtml) { + const heading = `

Frigate's own web UI

`; + if (!uiUrl) { + return ` +
+ ${heading} +

Frigate's full interface (recordings, clips, zones, detector settings) runs on this server's own loopback address, which your browser cannot open.

+

To reach it from your phone or another device, set FRIGATE_UI_URL in this extension's settings to the address your browser uses for Frigate — for example a Tailscale Serve HTTPS URL — then reload this page.

+
+ `; + } + return ` +
+ ${heading} +

Frigate's full interface (recordings, clips, zones, detector settings) opens in a new tab.

+ Open Frigate +

${escapeHtml(uiUrl)}

+
+ `; +} + function frigateStyles() { return ` .fg-panel { max-width: 1100px; margin: 0 auto; padding: 1rem; } @@ -98,6 +242,7 @@ function frigateStyles() { .fg-section h3 { margin: 0 0 0.5rem; font-size: 1rem; color: var(--muted, #888); text-transform: uppercase; letter-spacing: 0.05em; } .fg-cameras { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 0.75rem; } .fg-camera { padding: 0.75rem; background: var(--panel, #1a1a1a); border: 1px solid var(--border, #333); border-radius: 6px; } + .fg-cam-img { width: 100%; aspect-ratio: 16/9; object-fit: cover; background: #000; border-radius: 4px; display: block; margin-bottom: 0.5rem; } .fg-camera-name { font-weight: 600; margin-bottom: 0.25rem; } .fg-camera-meta { font-size: 0.85rem; color: var(--muted, #888); } .fg-badge { display: inline-block; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.75rem; margin-right: 0.25rem; } @@ -116,14 +261,20 @@ function frigateStyles() { .fg-loading { color: var(--muted, #888); font-style: italic; grid-column: 1 / -1; } .fg-empty { color: var(--muted, #888); font-style: italic; grid-column: 1 / -1; } .fg-error { color: #f43f5e; grid-column: 1 / -1; } - .fg-webui { height: calc(100vh - 220px); } - .fg-iframe { width: 100%; height: 100%; border: 0; border-radius: 6px; } + .fg-webui { max-width: 40rem; } + .fg-webui h3 { margin: 0 0 0.5rem; font-size: 1rem; } + .fg-webui p { color: var(--muted, #888); line-height: 1.5; } + .fg-webui code { font-family: monospace; } + .fg-webui-link { display: inline-block; margin: 0.5rem 0; padding: 0.5rem 1rem; border-radius: 6px; background: var(--accent, #d9a521); color: #111; font-weight: 600; text-decoration: none; } `; } function frigateScript() { return ` (function () { + var STILL_REFRESH_MS = 2000; + var stillTimer = null; + function clearKids(el) { while (el && el.firstChild) el.removeChild(el.firstChild); } function span(cls, text) { var s = document.createElement("span"); s.className = cls; s.textContent = text; return s; } function div(cls, text) { var d = document.createElement("div"); d.className = cls; if (text != null) d.textContent = text; return d; } @@ -144,6 +295,55 @@ function frigateScript() { return "fg-badge on"; } + // The gateway's still route, with the camera name URL-encoded. + function stillSrc(name) { + return "/api/frigate/" + encodeURIComponent(name) + "/latest.jpg"; + } + + function cameraStill(name) { + var img = document.createElement("img"); + img.className = "fg-cam-img"; + img.loading = "lazy"; + img.alt = name; + img.setAttribute("data-cam", name); + img.onerror = function () { this.style.display = "none"; }; + img.src = stillSrc(name); + return img; + } + + // Fetch the next still off-screen and swap it in only once it has loaded, so the + // card never flashes while it refreshes. + function refreshStill(img) { + var name = img.getAttribute("data-cam"); + if (!name) return; + var next = new Image(); + next.onload = function () { + img.style.display = ""; + img.src = next.src; + }; + next.src = stillSrc(name) + "?t=" + Date.now(); + } + + function refreshStills() { + var host = document.getElementById("fg-cameras"); + if (!host) { stopStills(); return; } // the page moved on (Turbo Drive) + var imgs = host.querySelectorAll(".fg-cam-img"); + for (var i = 0; i < imgs.length; i++) refreshStill(imgs[i]); + } + + // Only while the page is visible: a hidden tab asks Frigate for nothing. + function startStills(refreshNow) { + if (document.hidden) return; + if (refreshNow) refreshStills(); + if (!stillTimer) stillTimer = setInterval(refreshStills, STILL_REFRESH_MS); + } + + function stopStills() { + if (!stillTimer) return; + clearInterval(stillTimer); + stillTimer = null; + } + function renderCameras(cams) { var host = document.getElementById("fg-cameras"); if (!host) return; @@ -154,6 +354,7 @@ function frigateScript() { } cams.forEach(function (c) { var card = div("fg-camera"); + card.appendChild(cameraStill(c.name)); card.appendChild(div("fg-camera-name", c.name)); var meta = div("fg-camera-meta"); meta.appendChild(span(c.detect_enabled ? "fg-badge on" : "fg-badge off", c.detect_enabled ? "detect" : "detect off")); @@ -246,10 +447,14 @@ function frigateScript() { } if (document.getElementById("fg-cameras")) { + document.addEventListener("visibilitychange", function () { + if (document.hidden) stopStills(); else startStills(true); + }); + startStills(false); loadCameras(); loadEvents(); loadStats(); } })(); `; -} +} \ No newline at end of file diff --git a/bundles/frigate/panel/routes.js b/bundles/frigate/panel/routes.js index 4c53608a2..a6696c981 100644 --- a/bundles/frigate/panel/routes.js +++ b/bundles/frigate/panel/routes.js @@ -11,6 +11,11 @@ * TLS: we assume Frigate is configured with `tls.enabled: false` (our * config.yml.example default). See server/frigate-api.js for the * re-enable-TLS caveat. + * + * Logins are serialised: Frigate rate-limits `POST /api/login` (one per second by + * default), so a page view that fires several proxied reads at once produced one + * login per read and a pile of 429s reported as bad credentials. One in-flight + * login is shared by every caller, and a 429 is retried after a short pause. */ import { Router } from "express"; @@ -19,36 +24,72 @@ const FRIGATE_URL = () => (process.env.FRIGATE_URL || "http://localhost:8971").r const FRIGATE_USER = () => process.env.FRIGATE_USER || ""; const FRIGATE_PASSWORD = () => process.env.FRIGATE_PASSWORD || ""; +const BAD_CREDENTIALS_MESSAGE = "Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD"; +const RATE_LIMIT_MESSAGE = "Frigate is rate-limiting logins; try again in a moment"; +/** Frigate allows one login per second; wait past that window before trying again. */ +const LOGIN_RETRY_MS = 1100; +const LOGIN_MAX_RETRIES = 3; + let authToken = null; +/** The login every waiting caller shares, cleared as soon as it settles. */ +let loginInFlight = null; -async function getToken() { - if (authToken) return authToken; - if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +/** POST /api/login once, retrying a 429 a few times before giving up. */ +async function login() { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { - const res = await fetch(`${FRIGATE_URL()}/api/login`, { - method: "POST", - signal: controller.signal, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }), - }); - if (!res.ok) throw new Error("Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD"); - const data = await res.json().catch(() => ({})); - authToken = data.access_token || data.token || null; - if (!authToken) { - const setCookie = res.headers.get("set-cookie") || ""; - const match = setCookie.match(/frigate_token=([^;]+)/); - if (match) authToken = match[1]; + for (let attempt = 0; ; attempt++) { + const res = await fetch(`${FRIGATE_URL()}/api/login`, { + method: "POST", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }), + }); + if (res.status === 429) { + if (attempt >= LOGIN_MAX_RETRIES) throw new Error(RATE_LIMIT_MESSAGE); + await sleep(LOGIN_RETRY_MS); + continue; + } + if (res.status === 401 || res.status === 403) throw new Error(BAD_CREDENTIALS_MESSAGE); + if (!res.ok) throw new Error(`Frigate login failed: HTTP ${res.status}`); + + const data = await res.json().catch(() => ({})); + let token = data.access_token || data.token || null; + if (!token) { + const setCookie = res.headers.get("set-cookie") || ""; + const match = setCookie.match(/frigate_token=([^;]+)/); + if (match) token = match[1]; + } + if (!token) throw new Error("Frigate /api/login returned no token"); + return token; } - if (!authToken) throw new Error("Frigate /api/login returned no token"); - return authToken; } finally { clearTimeout(timeout); } } +async function getToken() { + if (authToken) return authToken; + if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null; + + if (!loginInFlight) { + loginInFlight = login() + .then((token) => { + authToken = token; + return token; + }) + .finally(() => { + loginInFlight = null; + }); + } + return loginInFlight; +} + async function frFetch(path, opts = {}) { const token = await getToken(); const controller = new AbortController(); diff --git a/bundles/frigate/server/frigate-api.js b/bundles/frigate/server/frigate-api.js index a3c1da142..2dce8c5fd 100644 --- a/bundles/frigate/server/frigate-api.js +++ b/bundles/frigate/server/frigate-api.js @@ -14,6 +14,11 @@ * * Also provides a 30s response cache on GET requests so chatty AI chat polling * (e.g. list_events every question) can't drive Frigate to 100% CPU. + * + * Logins are serialised: Frigate rate-limits `POST /api/login` (one per second by + * default), so several concurrent tools starting cold produced one login each and a + * pile of 429s reported as bad credentials. One in-flight login is shared by every + * caller, and a 429 is retried after a short pause. */ const FRIGATE_URL = () => (process.env.FRIGATE_URL || "http://localhost:8971").replace(/\/+$/, ""); @@ -21,39 +26,53 @@ const FRIGATE_USER = () => process.env.FRIGATE_USER || ""; const FRIGATE_PASSWORD = () => process.env.FRIGATE_PASSWORD || ""; const CACHE_TTL_MS = 30_000; +const BAD_CREDENTIALS_MESSAGE = "Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD"; +const RATE_LIMIT_MESSAGE = "Frigate is rate-limiting logins; try again in a moment"; +/** Frigate allows one login per second; wait past that window before trying again. */ +const LOGIN_RETRY_MS = 1100; +const LOGIN_MAX_RETRIES = 3; let authToken = null; +/** The login every waiting caller shares, cleared as soon as it settles. */ +let loginInFlight = null; const cache = new Map(); // key → { data, expires } -/** - * Authenticate with Frigate and obtain a JWT. Cached in-memory. - * Returns null if no credentials are configured (auth.enabled=false mode). - */ -async function getToken() { - if (authToken) return authToken; - if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null; +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} +/** POST /api/login once, retrying a 429 a few times before giving up. */ +async function login() { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { - const res = await fetch(`${FRIGATE_URL()}/api/login`, { - method: "POST", - signal: controller.signal, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }), - }); - if (!res.ok) throw new Error("Frigate login failed — check FRIGATE_USER / FRIGATE_PASSWORD"); - // Frigate returns JWT in JSON body AND as cookie; read JSON for the Bearer path. - const data = await res.json().catch(() => ({})); - authToken = data.access_token || data.token || null; - // Some builds only set cookie — fall back to cookie-only mode by stashing a sentinel. - if (!authToken) { - const setCookie = res.headers.get("set-cookie") || ""; - const match = setCookie.match(/frigate_token=([^;]+)/); - if (match) authToken = match[1]; + for (let attempt = 0; ; attempt++) { + const res = await fetch(`${FRIGATE_URL()}/api/login`, { + method: "POST", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: FRIGATE_USER(), password: FRIGATE_PASSWORD() }), + }); + if (res.status === 429) { + if (attempt >= LOGIN_MAX_RETRIES) throw new Error(RATE_LIMIT_MESSAGE); + await sleep(LOGIN_RETRY_MS); + continue; + } + if (res.status === 401 || res.status === 403) throw new Error(BAD_CREDENTIALS_MESSAGE); + if (!res.ok) throw new Error(`Frigate login failed: HTTP ${res.status}`); + + // Frigate returns JWT in JSON body AND as cookie; read JSON for the Bearer path. + const data = await res.json().catch(() => ({})); + let token = data.access_token || data.token || null; + // Some builds only set cookie — fall back to cookie-only mode by stashing a sentinel. + if (!token) { + const setCookie = res.headers.get("set-cookie") || ""; + const match = setCookie.match(/frigate_token=([^;]+)/); + if (match) token = match[1]; + } + if (!token) throw new Error("Frigate /api/login returned no token"); + return token; } - if (!authToken) throw new Error("Frigate /api/login returned no token"); - return authToken; } catch (err) { if (err.name === "AbortError") throw new Error("Frigate login timed out"); if (err.message.includes("fetch failed") || err.message.includes("ECONNREFUSED")) { @@ -65,6 +84,28 @@ async function getToken() { } } +/** + * Authenticate with Frigate and obtain a JWT. Cached in-memory; concurrent callers + * share one in-flight login instead of racing into Frigate's login rate limit. + * Returns null if no credentials are configured (auth.enabled=false mode). + */ +async function getToken() { + if (authToken) return authToken; + if (!FRIGATE_USER() || !FRIGATE_PASSWORD()) return null; + + if (!loginInFlight) { + loginInFlight = login() + .then((token) => { + authToken = token; + return token; + }) + .finally(() => { + loginInFlight = null; + }); + } + return loginInFlight; +} + /** * Perform an authenticated request against the Frigate API. * GETs are cached for 30s unless `opts.nocache` is set. diff --git a/docs/developers/port-allocation.md b/docs/developers/port-allocation.md index 84899b212..c6b331dde 100644 --- a/docs/developers/port-allocation.md +++ b/docs/developers/port-allocation.md @@ -122,6 +122,7 @@ These predate this registry and need follow-up resolution outside the MVP scope: | 8098 | 127.0.0.1 | searxng | MVP PR 5 | | 8456 | tailnet (Serve) | Tailscale Serve HTTPS → 127.0.0.1:3070 (Workspace; never Funnel) | W1 2026-10 | | 8457 | tailnet (Serve) | Tailscale Serve HTTPS → 127.0.0.1:3071 (Workspace editor; never Funnel). Plus one Serve PATH on the same port, no new port: `/crow-live` → `http://127.0.0.1:/api/workspace/live` (W2 K5 live-edit plugin; set up by the operator from Office › For the admin) | W1 2026-10, W2 path | +| 8458 | tailnet (Serve) | Tailscale Serve HTTPS → 127.0.0.1:8971 (Frigate's own web UI, opened by the Frigate panel's Web UI link via FRIGATE_UI_URL; never Funnel) | 2026-11 | | 8010 | 100.64.20.1 (tailscale) | llamacpp-vulkan-qwen36-27b-copilot — co-resident critic refute/probe model, 65536 ctx, text-only (crow-addons) | existing (2026-07-06) | | 8600 | 127.0.0.1 | funkwhale (nginx, `FUNKWHALE_NGINX_BIND_PORT`; bind address `FUNKWHALE_NGINX_BIND_ADDR`) | existing | | 8530 | 127.0.0.1 | adguard-home (DNS-over-TLS) | MVP PR 3 | diff --git a/docs/es/integrations/frigate.md b/docs/es/integrations/frigate.md index fc35cee7a..1619b5f65 100644 --- a/docs/es/integrations/frigate.md +++ b/docs/es/integrations/frigate.md @@ -11,9 +11,9 @@ Sistema de cámaras autoalojado con detección de objetos por IA. Frigate proces - Detección local de objetos por IA (persona, auto, perro, paquete, etc.) - Grabación activada por movimiento con retención configurable - API REST autenticada con JWT en el puerto :8971 -- Panel "Cameras" en el Crow's Nest: lista de cámaras, eventos recientes con miniaturas, estadísticas del sistema +- Panel "Cameras" en el Crow's Nest: tarjetas de cámara con una imagen que se refresca cada dos segundos, eventos recientes con miniaturas, estadísticas del sistema - Herramientas MCP para cámaras, eventos, snapshots, clips y activación/desactivación de la detección -- La interfaz web propia de Frigate integrada como pestaña en el panel del Nest +- Un enlace a la interfaz web propia de Frigate, en una pestaña nueva (configura `FRIGATE_UI_URL` con la dirección que usa tu navegador) ## Cuándo usarlo vs MotionEye @@ -58,6 +58,11 @@ Configura esto en `~/.crow/bundles/frigate/.env`: FRIGATE_URL=http://localhost:8971 FRIGATE_USER=admin FRIGATE_PASSWORD= +# La dirección que usa TU navegador para la interfaz web propia de Frigate (la pestaña +# Web UI del panel enlaza ahí, en una pestaña nueva). FRIGATE_URL es una dirección de +# loopback que solo alcanza el servidor, así que un teléfono no puede usarla. +# Ejemplo: https://frigate.example.ts.net — en blanco oculta el enlace. +# FRIGATE_UI_URL= ``` Luego reinicia el bundle para que el servidor MCP pueda autenticarse: @@ -81,6 +86,7 @@ cameras: - detect - record detect: + enabled: true width: 1280 height: 720 fps: 5 @@ -170,7 +176,7 @@ El bundle está instalado en `~/.crow/bundles/frigate/` pero faltan `panels.json y verifica que `~/.crow/panels/frigate.js` exista. Luego reinicia el gateway. **No se puede acceder a la pestaña de la interfaz web en el panel del Nest** -La cookie JWT de Frigate es de primera parte para `:8971`. El iframe debe cargarse desde el mismo host donde Frigate está publicado. Si el iframe del panel muestra una página en blanco, confirma que `FRIGATE_URL` apunte a una URL accesible desde el navegador. +La interfaz de Frigate corre en `:8971`, vinculado a loopback: solo el servidor de Crow puede abrir esa dirección, y un teléfono que abre `localhost:8971` se alcanza a sí mismo. Configura `FRIGATE_UI_URL` con una dirección que tu navegador sí alcance — una URL HTTPS de Tailscale Serve suele ser la respuesta — y la pestaña Web UI enlazará ahí. Si queda en blanco, la pestaña lo explica en lugar de mostrar un enlace. ## Trabajo futuro diff --git a/docs/integrations/frigate.md b/docs/integrations/frigate.md index e7f53f937..f34686bba 100644 --- a/docs/integrations/frigate.md +++ b/docs/integrations/frigate.md @@ -11,9 +11,9 @@ Self-hosted camera system with AI object detection. Frigate processes RTSP/ONVIF - Local AI object detection (person, car, dog, package, etc.) - Motion-triggered recording with configurable retention - JWT-authenticated REST API on :8971 -- Crow's Nest "Cameras" panel: camera list, recent events with thumbnails, system stats +- Crow's Nest "Cameras" panel: camera cards with a still that refreshes every couple of seconds, recent events with thumbnails, system stats - MCP tools for cameras, events, snapshots, clips, and detect toggle -- Frigate's own Web UI embedded as a tab in the Nest panel +- A link to Frigate's own web UI, in a new tab (set `FRIGATE_UI_URL` to the address your browser uses) ## When to Use vs MotionEye @@ -58,6 +58,10 @@ Set these in `~/.crow/bundles/frigate/.env`: FRIGATE_URL=http://localhost:8971 FRIGATE_USER=admin FRIGATE_PASSWORD= +# The address YOUR browser uses for Frigate's own web UI (the panel's Web UI tab links +# to it in a new tab). FRIGATE_URL is a loopback address only the server can reach, so a +# phone cannot use it. Example: https://frigate.example.ts.net — blank hides the link. +# FRIGATE_UI_URL= ``` Then restart the bundle so the MCP server can authenticate: @@ -81,6 +85,7 @@ cameras: - detect - record detect: + enabled: true width: 1280 height: 720 fps: 5 @@ -169,8 +174,8 @@ The bundle is installed to `~/.crow/bundles/frigate/` but `panels.json` / panel and verify `~/.crow/panels/frigate.js` exists. Then restart the gateway. -**Can't reach Web UI tab in the Nest panel** -Frigate's JWT cookie is first-party to `:8971`. The iframe must be loaded from the same host where Frigate is published. If the panel iframe shows a blank page, confirm `FRIGATE_URL` points to a browser-reachable URL. +**Can't reach the Web UI tab in the Nest panel** +Frigate's UI runs on `:8971`, which is bound to loopback: only the Crow server can open that address, and a phone that opens `localhost:8971` reaches itself. Set `FRIGATE_UI_URL` to an address your browser can reach — a Tailscale Serve HTTPS URL is the usual answer — and the Web UI tab will link to it. Left blank, the tab explains this instead of showing a link. ## Future Work diff --git a/registry/add-ons.json b/registry/add-ons.json index 5db8f9511..3b4b35949 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -1533,7 +1533,7 @@ { "id": "frigate", "name": "Frigate NVR", - "version": "1.1.1", + "version": "1.2.0", "description": "Self-hosted NVR with AI object detection — cameras, events, clips. RTSP/ONVIF cameras, local CPU detection, optional Coral TPU.", "type": "bundle", "author": "Crow", @@ -1600,6 +1600,11 @@ "default": "http://localhost:8971", "required": true }, + { + "name": "FRIGATE_UI_URL", + "description": "Address your browser uses to open Frigate's own web UI, e.g. a Tailscale Serve HTTPS URL. Leave blank to hide the Web UI link.", + "required": false + }, { "name": "FRIGATE_USER", "description": "Admin user for JWT login (POST /api/login). Omit with FRIGATE_PASSWORD if auth.enabled=false in config.yml.", diff --git a/tests/frigate-panel.test.js b/tests/frigate-panel.test.js new file mode 100644 index 000000000..d1b28fbe3 --- /dev/null +++ b/tests/frigate-panel.test.js @@ -0,0 +1,434 @@ +/** + * The Frigate bundle's panel page and its login rail (panel/frigate.js, panel/routes.js, + * server/frigate-api.js). + * + * Why these assertions: on a phone the panel showed no picture (the stills were never + * asked for) and the Web UI tab was an iframe on `localhost:8971`, which a phone resolves + * to itself — the dashboard answered ERR_BLOCKED_BY_CSP. And every page view that fanned + * out into several proxied reads started its own login; Frigate allows one login per + * second, so the losers got 429s and the owner was told their password was wrong. + * + * Rendered pages are parsed with linkedom; the login tests drive the real route handlers + * off the router the gateway mounts, with `fetch` stubbed, so no socket is opened. + */ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseHTML } from "linkedom"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const BUNDLE = join(ROOT, "bundles", "frigate"); +const MANIFEST = JSON.parse(readFileSync(join(BUNDLE, "manifest.json"), "utf8")); +const panel = (await import("../bundles/frigate/panel/frigate.js")).default; + +const SCRATCH_HOME = mkdtempSync(join(tmpdir(), "crow-frigate-panel-")); +const ENV_KEYS = ["CROW_HOME", "FRIGATE_URL", "FRIGATE_UI_URL", "FRIGATE_USER", "FRIGATE_PASSWORD"]; +const SAVED_ENV = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + +// A CROW_HOME with no installed bundle: the panel then reads its own client from appRoot, +// so an old installed copy in the developer's home cannot answer for this checkout. +process.env.CROW_HOME = SCRATCH_HOME; +process.env.FRIGATE_URL = "http://frigate.invalid:8971"; +delete process.env.FRIGATE_UI_URL; +delete process.env.FRIGATE_USER; +delete process.env.FRIGATE_PASSWORD; + +after(() => { + for (const [k, v] of Object.entries(SAVED_ENV)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(SCRATCH_HOME, { recursive: true, force: true }); +}); + +/** A response object shaped the way the code reads one. */ +function jsonResponse(body, { status = 200, headers = {} } = {}) { + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + headers: { get: (name) => headers[String(name).toLowerCase()] ?? null }, + json: async () => body, + text: async () => JSON.stringify(body), + arrayBuffer: async () => new ArrayBuffer(0), + }; +} + +/** Replace global fetch for one test; `handler` sees (url, init). */ +function stubFetch(handler) { + const original = globalThis.fetch; + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), init }); + return handler(String(url), init); + }; + return { calls, restore() { globalThis.fetch = original; } }; +} + +let importCounter = 0; +/** A fresh copy of a module, so its cached token does not leak into the next case. */ +const freshImport = (path) => import(`${path}?frigate-panel-test=${++importCounter}`); + +/** Drives the real panel handler and returns the page content the layout was handed. */ +async function renderPanel(query = {}) { + let content = null; + const res = { send(value) { content = value; return this; } }; + await panel.handler({ query }, res, { + appRoot: ROOT, + layout: (opts) => opts.content, + }); + assert.equal(typeof content, "string", "the panel sent a page"); + return content; +} + +const page = (html) => parseHTML(`${html}`).document; + +// ─── the Overview: a still on every camera card ─────────────────────────── + +// Order note: this bundle's client caches one successful /api/config for 30s, and the panel +// loads that same module copy for every render in this file. The unreadable-Frigate case +// therefore runs first, while nothing is cached yet (a failure is not cached). + +test("the Overview still draws when Frigate cannot be read: the browser fills the grid", async () => { + const stub = stubFetch(() => jsonResponse({ error: "boom" }, { status: 500 })); + let html; + try { + html = await renderPanel(); + } finally { + stub.restore(); + } + const document = page(html); + assert.match(document.getElementById("fg-cameras").textContent, /Loading cameras/, + "the page keeps its loading line instead of failing to render"); + assert.equal(document.querySelectorAll("#fg-cameras img").length, 0); +}); + +test("the Overview carries a still for every camera, its name encoded and escaped", async () => { + const cameras = { + front: { detect: { width: 1920, height: 1080 }, objects: { track: ["person", "car"] } }, + "cam&": { detect: { enabled: false } }, + }; + const stub = stubFetch((url) => { + if (url.endsWith("/api/config")) return jsonResponse({ cameras }); + throw new Error(`unexpected read: ${url}`); + }); + let html; + try { + html = await renderPanel(); + } finally { + stub.restore(); + } + + const document = page(html); + const imgs = [...document.querySelectorAll("#fg-cameras img")]; + assert.equal(imgs.length, 2, "one still per camera, in config order"); + assert.equal(imgs[0].getAttribute("src"), "/api/frigate/front/latest.jpg"); + assert.equal(imgs[1].getAttribute("src"), "/api/frigate/cam%26%3Cx%3E/latest.jpg", + "a camera name is URL-encoded into the gateway's still route"); + assert.equal(imgs[0].getAttribute("loading"), "lazy"); + assert.equal(imgs[0].getAttribute("alt"), "front"); + assert.equal(imgs[1].getAttribute("alt"), "cam&", "the attribute decodes back to the name"); + + assert.ok(html.includes('data-cam="cam&<x>"'), "the name is HTML-escaped in markup"); + assert.ok(!html.includes("cam"), "no unescaped camera name reaches the page"); + assert.equal(document.querySelectorAll("#fg-cameras .fg-camera").length, 2); + assert.match(document.querySelectorAll("#fg-cameras .fg-camera")[0].textContent, /1920×1080/); + assert.match(document.querySelectorAll("#fg-cameras .fg-camera")[0].textContent, /tracking: person, car/); + assert.match(document.querySelectorAll("#fg-cameras .fg-camera")[1].textContent, /detect off/); + + // The first paint has no cache-buster: the refresh adds one. + assert.ok(imgs.every((img) => !img.getAttribute("src").includes("?")), "no query on the first paint"); +}); + +test("the client keeps the stills fresh: two seconds, load-then-swap, paused while hidden", async () => { + const stub = stubFetch((url) => { + if (url.endsWith("/api/config")) return jsonResponse({ cameras: { front: {} } }); + throw new Error(`unexpected read: ${url}`); + }); + let html; + try { + html = await renderPanel(); + } finally { + stub.restore(); + } + + const script = /