@@ -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 = /