From 2667967cf0166b2aba7f8a4e8887eb7ae266e82f Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 20:00:30 -0500 Subject: [PATCH] Say what a scan is doing, and remember where MediaMTX went (0.1.3) - A scan no longer looks hung: the button counts seconds against the chosen timeout, the page says in words that it is listening for cameras and asking every address in turn, the refresh afterwards has its own state, and the result reports how many cameras were found, how long it took and how many addresses were checked. scanNetwork() returns that report; discoverCameras() still exists on top of it. - The config pointer now remembers the binary folder beside the data folder, so install-video and start can't disagree about where MediaMTX lives, whichever directory each is run from. Checked through an installed tarball against the real network: 2 cameras found, 4,093 addresses probed, 3.0 s. Co-Authored-By: Claude Opus 5 (1M context) --- .vrek/log.ndjson | 20 ++++++++ cli/onvif-dashboard.mjs | 12 ++++- package.json | 2 +- src/app/api/discover/route.test.ts | 19 +++---- src/app/api/discover/route.ts | 8 +-- src/app/camera-queries.ts | 8 ++- src/app/camera-scanner.test.tsx | 60 ++++++++++++++++------ src/app/camera-scanner.tsx | 82 +++++++++++++++++++++++++----- src/app/icons.test.tsx | 4 +- src/lib/first-run.test.ts | 36 +++++++++++-- src/lib/first-run.ts | 45 ++++++++++++---- src/lib/onvif.ts | 21 ++++++-- 12 files changed, 251 insertions(+), 66 deletions(-) diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 3673883..a6b96e1 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -1093,3 +1093,23 @@ {"id":"evt-dqc3aswb5n83","type":"edge.added","subject":"fnd-53mh3ad","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-53mh3ad","to":"iss-9zcawn7"},"at":"2026-09-20T00:21:38.493Z","parents":["evt-5qz5x14089hy"],"hash":"7827762965307b09d8be5fe9d83a7720c2efa1998bd66a62020ced96c5e2e081"} {"id":"evt-9yg21vwz860k","type":"edge.added","subject":"fnd-53mh3ad","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-53mh3ad","to":"gol-k56hc69"},"at":"2026-09-20T00:21:38.494Z","parents":["evt-dqc3aswb5n83"],"hash":"7398dd055f23e646e167823526004382e417186b7464ca8b984739848aace69c"} {"id":"evt-6zk4dghxrwx7","type":"edge.added","subject":"fnd-53mh3ad","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-53mh3ad","to":"gol-9jctv92"},"at":"2026-09-20T00:21:38.495Z","parents":["evt-9yg21vwz860k"],"hash":"69f9257312d727fc3f3681910a8da37cb69f451ae65e190595510652f8180337"} +{"id":"evt-zgchgcs8qbz9","type":"node.created","subject":"iss-jwdt0pw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Show what a network scan is doing while it runs","body":"Reported by the user on 2026-09-20 from the published package: the first scan looks like it has hung. It eventually works, but nothing says what is happening. Today the button shows a spinner and \"Scanning…\", and that is all; the scan is a multicast probe and a unicast sweep of the subnet running together (both bounded by the timeout), and afterwards router.refresh() re-renders the dashboard and every card then fetches its own info and snapshot, which is more waiting with no explanation. Make it legible: count the seconds while scanning against the timeout the user chose, say in words what is happening (listening for cameras, and checking every address on this subnet), keep a \"Updating the list…\" state while the page refreshes (useTransition), and afterwards report what was done, e.g. how many addresses were checked, how long it took and how many cameras were found. The discovery layer already knows the host count, so the route can return it (a small addition to the response the client already reads).","status":"open","owner":null,"attrs":{}},"at":"2026-09-20T00:54:20.498Z","parents":["evt-6zk4dghxrwx7"],"hash":"6239057b6d1e8df41a5cf4a399606adaa532e871b6ed5a522f18e12648e47bbc"} +{"id":"evt-09x228gd0ptb","type":"edge.added","subject":"iss-jwdt0pw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-jwdt0pw","to":"gol-k56hc69"},"at":"2026-09-20T00:54:20.502Z","parents":["evt-zgchgcs8qbz9"],"hash":"a443be68d300ba64aa27d35d1346c4cbec729503faca1e293180958183641223"} +{"id":"evt-gpbe57gp6v00","type":"edge.added","subject":"iss-jwdt0pw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-jwdt0pw","to":"src/app/camera-scanner.tsx"},"at":"2026-09-20T00:54:20.503Z","parents":["evt-09x228gd0ptb"],"hash":"9940073415d6ca35ce9e41f7e85bbfc57baf5cb173eeaddc6afaf4a350713c0e"} +{"id":"evt-v1bthmjhe3v8","type":"edge.added","subject":"iss-jwdt0pw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-jwdt0pw","to":"src/app/api/discover/route.ts"},"at":"2026-09-20T00:54:20.504Z","parents":["evt-gpbe57gp6v00"],"hash":"0144b20bd1f367c42202ab75fbd16cc2019211c97761e8bfff4b92e72698a483"} +{"id":"evt-myb3dwzvdbg4","type":"edge.added","subject":"iss-jwdt0pw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-jwdt0pw","to":"src/lib/onvif.ts"},"at":"2026-09-20T00:54:20.505Z","parents":["evt-v1bthmjhe3v8"],"hash":"b9b38dfc09596499c2a8a25c42d492477eca716a4cf1c2e4d85712e43216379c"} +{"id":"evt-chx9hkeb0sp9","type":"edge.added","subject":"iss-jwdt0pw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-jwdt0pw","to":"area:ui"},"at":"2026-09-20T00:54:20.506Z","parents":["evt-myb3dwzvdbg4"],"hash":"9b915eef0997644a5af4fcfe9e5b8ad06be1e0e6c79203123f8bef07e5864960"} +{"id":"evt-yjrqfm80ndyh","type":"node.created","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Remember the binary folder like the data folder, so install-video and start can't disagree","body":"Found by the user on 2026-09-20: onvif-dashboard install-video put MediaMTX in ./bin relative to the directory it was run in, and starting from a different directory looked elsewhere and reported it missing. Commit 731206b stopped the server from resolving it against its own directory, but the folder still follows whoever runs the command. Fix: keep it in the same config pointer that already remembers the data folder, written by install-video and setup and read by start, with CAMERAS_BIN_DIR still winning. Tests cover installing from one directory and starting from another.","status":"open","owner":null,"attrs":{}},"at":"2026-09-20T00:55:11.103Z","parents":["evt-chx9hkeb0sp9"],"hash":"2716518e0ebb924e1c49a413f630f86230cc737415bfea562c2460473070e7fc"} +{"id":"evt-pygtmv7cqz1n","type":"edge.added","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-n8yz5cy","to":"gol-k56hc69"},"at":"2026-09-20T00:55:11.116Z","parents":["evt-yjrqfm80ndyh"],"hash":"c6c91d60203783516728aecae5e493d2d1bf316a40b4c530fece09a679cdffe0"} +{"id":"evt-ak71wvdrjn34","type":"edge.added","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-n8yz5cy","to":"gol-9jctv92"},"at":"2026-09-20T00:55:11.117Z","parents":["evt-pygtmv7cqz1n"],"hash":"2b9fcfe6e8ecdb335da0a9f4f18976f02b580ba3da9e53288c6df0ac20d9c0bc"} +{"id":"evt-zsct72dx114b","type":"edge.added","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-n8yz5cy","to":"src/lib/first-run.ts"},"at":"2026-09-20T00:55:11.118Z","parents":["evt-ak71wvdrjn34"],"hash":"c52f88638842fb1ee33fe4c4df0e154bf78d8a63f4b95331a0aa6f5acaaf8c3f"} +{"id":"evt-1gdd72r0sq08","type":"edge.added","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-n8yz5cy","to":"cli/onvif-dashboard.mjs"},"at":"2026-09-20T00:55:11.119Z","parents":["evt-zsct72dx114b"],"hash":"de26356dcf8dc5f709abc6af8206f4c9694c6b3f30cd4f0fcbcbd85c65ac74de"} +{"id":"evt-45fchc2hna2c","type":"edge.added","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-n8yz5cy","to":"area:packaging"},"at":"2026-09-20T00:55:11.120Z","parents":["evt-1gdd72r0sq08"],"hash":"0117efc99bf9614015110e3cf827e77aea6b49e2decdadba24568869adaf47b0"} +{"id":"evt-mm3wj70pzwa5","type":"node.created","subject":"ver-nh5yt09","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A running scan says what it is doing: seconds counted against the chosen timeout, the phases in plain words, a state while the list updates, and a summary of what was found and how many addresses were checked","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T01:00:12.246Z","parents":["evt-45fchc2hna2c"],"hash":"c757f0c934bf4f8e16f87844e3160d2b3bfd486a16567dfecec48faf4966fead"} +{"id":"evt-qch17sfhy9w8","type":"edge.added","subject":"ver-nh5yt09","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-nh5yt09","to":"iss-jwdt0pw"},"at":"2026-09-20T01:00:12.250Z","parents":["evt-mm3wj70pzwa5"],"hash":"9b63c90170cd91a5b19b576770338a79da6a52001ef45deabb8c00ba692aae38"} +{"id":"evt-kqt47cssb3nw","type":"verification.recorded","subject":"ver-nh5yt09","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/onvif.ts gained scanNetwork() returning cameras, addressesProbed and durationMs (discoverCameras still exists on top of it); the route returns that report; camera-scanner.tsx counts elapsed seconds, describes both phases, uses useTransition for the refresh and prints a summary. Tests: camera-scanner.test.tsx (summary wording, singular, phase text with and without the sweep, controls disabled while busy), discover route tests, icons test. Checked through an installed 0.1.3 tarball against the real network: 2 cameras found, 4,093 addresses probed, 3,005 ms. Full suite 944 tests pass, 99.65% lines; tsc and eslint clean."},"at":"2026-09-20T01:00:12.251Z","parents":["evt-qch17sfhy9w8"],"hash":"e0401cbfb81e2499d61d3ccc466dbd32aac728f93e3d51223623276249f094de"} +{"id":"evt-vrrs8ncbcghg","type":"node.created","subject":"ver-nah7fyr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"install-video and start agree about where MediaMTX lives, whichever directory each is run from","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T01:00:15.663Z","parents":["evt-kqt47cssb3nw"],"hash":"6c3cdeef9833268401ca110910115fc5b2d88edcc50d1ca16e0beb1cfe89da51"} +{"id":"evt-ek7eke0zxksj","type":"edge.added","subject":"ver-nah7fyr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-nah7fyr","to":"iss-n8yz5cy"},"at":"2026-09-20T01:00:15.664Z","parents":["evt-vrrs8ncbcghg"],"hash":"f748f78bee2f96fd15c0449c89ac0f8a09c353b2a4ad8753df783eeb4ee4a794"} +{"id":"evt-teap5q282n28","type":"verification.recorded","subject":"ver-nah7fyr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"The config pointer now remembers binDir alongside dataDir, merging rather than overwriting; install-video calls rememberBinDir() and installs there; applyEnvironment feeds it back to later commands, with CAMERAS_BIN_DIR still winning. src/lib/first-run.test.ts covers remembering, keeping the data folder, and environment-over-remembered. Checked by hand: installed from /tmp/od5/here, then read back from /tmp/od5/elsewhere as /private/tmp/od5/here/bin."},"at":"2026-09-20T01:00:15.665Z","parents":["evt-ek7eke0zxksj"],"hash":"40a4b4122f6b2c48ed3ee8bf93ba9b5f4dda7e9c10fdf8526633ef1a4aef8e72"} +{"id":"evt-fj77a4q216h5","type":"node.status_changed","subject":"iss-jwdt0pw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T01:00:16.813Z","parents":["evt-teap5q282n28"],"hash":"73a72c267bdf0504d269e6380884aadd3d121f82ebd05365680e9f12d9001515"} +{"id":"evt-954awzf35t82","type":"node.status_changed","subject":"iss-n8yz5cy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T01:00:17.828Z","parents":["evt-fj77a4q216h5"],"hash":"98b76ddba2a716012a86e7b526e82dadd9f7b3af6f0b0351b0c60965af4a24c2"} diff --git a/cli/onvif-dashboard.mjs b/cli/onvif-dashboard.mjs index 0f51a25..6e4e4bd 100755 --- a/cli/onvif-dashboard.mjs +++ b/cli/onvif-dashboard.mjs @@ -6,7 +6,13 @@ import { access } from "node:fs/promises"; import { createInterface } from "node:readline/promises"; import { parseArgs } from "node:util"; import path from "node:path"; -import { applyEnvironment, firstRun, SECRETS_FILE, serverEnvironment } from "../dist/lib/first-run.js"; +import { + applyEnvironment, + firstRun, + rememberBinDir, + SECRETS_FILE, + serverEnvironment, +} from "../dist/lib/first-run.js"; import { installMediamtx } from "../dist/lib/mediamtx-install.js"; import { adminFilePath, readAdminFile } from "../dist/lib/admin-file.js"; @@ -101,7 +107,9 @@ async function admin(args) { } async function installVideo() { - const result = await installMediamtx(); + // Remember where it goes, so starting from another directory still finds it. + const binDir = await rememberBinDir(); + const result = await installMediamtx({ binDir }); console.log( result.status === "installed" ? `Installed MediaMTX ${result.version} at ${result.binary} (checksum verified).` diff --git a/package.json b/package.json index f850e62..11ce106 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "onvif-dashboard", - "version": "0.1.2", + "version": "0.1.3", "description": "Web dashboard for ONVIF cameras (Hikvision, Annke): discovery, live video over WebRTC, motion recording and settings, all on your own network.", "keywords": [ "onvif", diff --git a/src/app/api/discover/route.test.ts b/src/app/api/discover/route.test.ts index 9328c0a..c79b6a3 100644 --- a/src/app/api/discover/route.test.ts +++ b/src/app/api/discover/route.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const discoverCameras = vi.fn(); +const scanNetwork = vi.fn(); const recordDiscovered = vi.fn(); // Access control is tested in src/app/api/access.test.ts; here requests are allowed. vi.mock("@/lib/access", () => ({ apiAccessDenied: async () => null })); vi.mock("@/lib/onvif", async (importOriginal) => ({ ...(await importOriginal()), - discoverCameras, + scanNetwork, })); vi.mock("@/lib/camera-registry", () => ({ recordDiscovered })); @@ -27,21 +27,22 @@ const camera = { id: "abc", urn: "urn:uuid:abc", hostname: "192.168.1.10", port: describe("POST /api/discover", () => { beforeEach(() => { - discoverCameras.mockReset().mockResolvedValue([camera]); + scanNetwork.mockReset().mockResolvedValue({ cameras: [camera], addressesProbed: 254, durationMs: 5100 }); recordDiscovered.mockReset().mockResolvedValue(undefined); }); it("scans with defaults when the body is empty, and records the result", async () => { const res = await post(); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ cameras: [camera] }); - expect(discoverCameras).toHaveBeenCalledWith({ timeoutMs: 5000, unicastSweep: true }); + // The report travels to the client, so it can say what the scan did (iss-jwdt0pw). + expect(await res.json()).toEqual({ cameras: [camera], addressesProbed: 254, durationMs: 5100 }); + expect(scanNetwork).toHaveBeenCalledWith({ timeoutMs: 5000, unicastSweep: true }); expect(recordDiscovered).toHaveBeenCalledWith([camera]); }); it("passes validated options through", async () => { await post({ timeout: 2000, unicastSweep: false }); - expect(discoverCameras).toHaveBeenCalledWith({ timeoutMs: 2000, unicastSweep: false }); + expect(scanNetwork).toHaveBeenCalledWith({ timeoutMs: 2000, unicastSweep: false }); }); it.each([ @@ -53,7 +54,7 @@ describe("POST /api/discover", () => { const res = await post(body); expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: "Invalid scan options" }); - expect(discoverCameras).not.toHaveBeenCalled(); + expect(scanNetwork).not.toHaveBeenCalled(); }); // iss-dbwgww8: OS and socket errors must stay in the server log. @@ -61,9 +62,9 @@ describe("POST /api/discover", () => { const secret = "EISDIR: illegal operation on a directory, open '/secret/path/cameras.json'"; it.each([ - ["discovery throws", () => discoverCameras.mockRejectedValue(new Error(secret))], + ["discovery throws", () => scanNetwork.mockRejectedValue(new Error(secret))], ["recording results throws", () => recordDiscovered.mockRejectedValue(new Error(secret))], - ["a non-Error is thrown", () => discoverCameras.mockRejectedValue(secret)], + ["a non-Error is thrown", () => scanNetwork.mockRejectedValue(secret)], ])("returns a generic 500 when %s", async (_label, arrange) => { arrange(); const log = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts index 95fa2b0..c67b1ed 100644 --- a/src/app/api/discover/route.ts +++ b/src/app/api/discover/route.ts @@ -1,6 +1,6 @@ import { apiAccessDenied } from "@/lib/access"; import { recordDiscovered } from "@/lib/camera-registry"; -import { discoverCameras, discoverRequestSchema } from "@/lib/onvif"; +import { discoverRequestSchema, scanNetwork } from "@/lib/onvif"; export async function POST(request: Request) { const denied = await apiAccessDenied(); @@ -14,9 +14,9 @@ export async function POST(request: Request) { const { timeout, unicastSweep } = parsed.data; try { - const cameras = await discoverCameras({ timeoutMs: timeout, unicastSweep }); - await recordDiscovered(cameras); - return Response.json({ cameras }); + const report = await scanNetwork({ timeoutMs: timeout, unicastSweep }); + await recordDiscovered(report.cameras); + return Response.json(report); } catch (err) { // OS and socket errors can carry interface names and addresses; keep them in the server log. console.error("Network scan failed", err); diff --git a/src/app/camera-queries.ts b/src/app/camera-queries.ts index 8789fb1..8a613af 100644 --- a/src/app/camera-queries.ts +++ b/src/app/camera-queries.ts @@ -139,8 +139,14 @@ export interface ScanOptions { unicastSweep: boolean; } +export interface ScanReport { + cameras: DiscoveredCamera[]; + addressesProbed: number; + durationMs: number; +} + export function useScanNetwork() { - return useMutation<{ cameras: DiscoveredCamera[] }, CameraProblem, ScanOptions>({ + return useMutation({ mutationFn: ({ timeoutSecs, unicastSweep }) => requestJson("/api/discover", { method: "POST", diff --git a/src/app/camera-scanner.test.tsx b/src/app/camera-scanner.test.tsx index 5002288..4c58baf 100644 --- a/src/app/camera-scanner.test.tsx +++ b/src/app/camera-scanner.test.tsx @@ -13,23 +13,50 @@ const body = (fetchMock: ReturnType) => describe("CameraScanner", () => { beforeEach(() => refresh.mockReset()); - it("scans, reports the count and refreshes the server-rendered list", async () => { + it("scans, says what it found and refreshes the server-rendered list", async () => { const fetchMock = stubFetch({ - "POST /api/discover": () => json({ cameras: [{ id: "a" }, { id: "b" }] }), + "POST /api/discover": () => + json({ cameras: [{ id: "a" }, { id: "b" }], addressesProbed: 4094, durationMs: 5200 }), }); renderWithQuery(); - fireEvent.click(screen.getByRole("button", { name: "Scan network" })); - expect(await screen.findByText("Scan found 2 cameras")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); + expect(await screen.findByText("Found 2 cameras in 5s after checking 4,094 addresses.")).toBeTruthy(); expect(refresh).toHaveBeenCalledTimes(1); expect(body(fetchMock)).toEqual({ timeout: 5000, unicastSweep: true }); }); - it("uses the singular for one camera", async () => { - stubFetch({ "POST /api/discover": () => json({ cameras: [{ id: "a" }] }) }); + it("uses the singular for one camera, and leaves out a sweep that didn't run", async () => { + stubFetch({ + "POST /api/discover": () => json({ cameras: [{ id: "a" }], addressesProbed: 0, durationMs: 900 }), + }); renderWithQuery(); - fireEvent.click(screen.getByRole("button", { name: "Scan network" })); - expect(await screen.findByText("Scan found 1 camera")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); + expect(await screen.findByText("Found 1 camera in 1s.")).toBeTruthy(); + }); + + it("says what it is doing while it runs, and while the list updates", async () => { + let finish!: (res: Response) => void; + stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) }); + renderWithQuery(); + + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); + const status = await screen.findByRole("status"); + expect(status.textContent).toContain("Listening for cameras that announce themselves"); + expect(status.textContent).toContain("asking every address on this network in turn"); + expect(status.textContent).toContain("runs for the full 5 seconds"); + expect(screen.getByRole("button", { name: /Scanning… \ds of 5s/ })).toBeTruthy(); + + finish(json({ cameras: [], addressesProbed: 10, durationMs: 1000 })); + await screen.findByRole("button", { name: /Scan network/ }); + }); + + it("drops the sweep from the description when it is switched off", async () => { + stubFetch({ "POST /api/discover": () => new Promise(() => {}) }); + renderWithQuery(); + fireEvent.click(screen.getByLabelText("Unicast sweep of local subnet")); + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); + expect((await screen.findByRole("status")).textContent).not.toContain("every address"); }); it("disables the button while scanning", async () => { @@ -37,11 +64,12 @@ describe("CameraScanner", () => { stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) }); renderWithQuery(); - fireEvent.click(screen.getByRole("button", { name: "Scan network" })); - const button = await screen.findByRole("button", { name: "Scanning…" }); + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); + const button = await screen.findByRole("button", { name: /Scanning…/ }); expect(button.disabled).toBe(true); - finish(json({ cameras: [] })); - await screen.findByRole("button", { name: "Scan network" }); + expect(screen.getByLabelText("Unicast sweep of local subnet")).toHaveProperty("disabled", true); + finish(json({ cameras: [], addressesProbed: 0, durationMs: 100 })); + await screen.findByRole("button", { name: /Scan network/ }); }); it.each([ @@ -50,12 +78,12 @@ describe("CameraScanner", () => { ["a value below 1 s", "0.2", 1000], ["an empty field", "", 5000], ])("sends the timeout clamped to 1–30 s for %s", async (_label, typed, expected) => { - const fetchMock = stubFetch({ "POST /api/discover": () => json({ cameras: [] }) }); + const fetchMock = stubFetch({ "POST /api/discover": () => json({ cameras: [], addressesProbed: 0, durationMs: 100 }) }); renderWithQuery(); fireEvent.change(screen.getByRole("spinbutton"), { target: { value: typed } }); fireEvent.click(screen.getByRole("checkbox")); - fireEvent.click(screen.getByRole("button", { name: "Scan network" })); + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); await waitFor(() => expect(calls(fetchMock, "POST /api/discover")).toHaveLength(1)); expect(body(fetchMock)).toEqual({ timeout: expected, unicastSweep: false }); }); @@ -64,7 +92,7 @@ describe("CameraScanner", () => { stubFetch({ "POST /api/discover": () => json({ error: "Network scan failed" }, 500) }); renderWithQuery(); - fireEvent.click(screen.getByRole("button", { name: "Scan network" })); + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); expect(await screen.findByText("Error: Network scan failed")).toBeTruthy(); expect(refresh).not.toHaveBeenCalled(); }); @@ -73,7 +101,7 @@ describe("CameraScanner", () => { stubFetch({ "POST /api/discover": () => new Response("oops", { status: 503 }) }); renderWithQuery(); - fireEvent.click(screen.getByRole("button", { name: "Scan network" })); + fireEvent.click(screen.getByRole("button", { name: /Scan network/ })); expect(await screen.findByText("Error: HTTP 503")).toBeTruthy(); }); }); diff --git a/src/app/camera-scanner.tsx b/src/app/camera-scanner.tsx index 6947261..6d582dc 100644 --- a/src/app/camera-scanner.tsx +++ b/src/app/camera-scanner.tsx @@ -2,36 +2,79 @@ import { CircleAlert, LoaderCircle, Radar } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { useScanNetwork } from "./camera-queries"; +import { useEffect, useRef, useState, useTransition } from "react"; +import { useScanNetwork, type ScanReport } from "./camera-queries"; /** * Runs a network scan. The scan writes the server-side registry, so afterwards the * page is refreshed and the server re-renders the camera list. + * + * A scan takes as long as the timeout, and the refresh after it takes longer still, so it + * says what it is doing throughout: without that it looks like it has hung (vrek + * iss-jwdt0pw). */ + +/** Counts seconds while a scan runs, so the wait is visibly alive. `start` resets it. */ +function useElapsedSeconds(running: boolean): { seconds: number; start: () => void } { + const [seconds, setSeconds] = useState(0); + const startedAt = useRef(0); + + useEffect(() => { + if (!running) return; + const timer = setInterval( + () => setSeconds(Math.round((Date.now() - startedAt.current) / 1000)), + 500, + ); + return () => clearInterval(timer); + }, [running]); + + return { + seconds: running ? seconds : 0, + start: () => { + startedAt.current = Date.now(); + setSeconds(0); + }, + }; +} + +function summary(report: ScanReport): string { + const cameras = `${report.cameras.length} camera${report.cameras.length === 1 ? "" : "s"}`; + const seconds = Math.max(1, Math.round(report.durationMs / 1000)); + const addresses = report.addressesProbed + ? ` after checking ${report.addressesProbed.toLocaleString("en-US")} addresses` + : ""; + return `Found ${cameras} in ${seconds}s${addresses}.`; +} + export default function CameraScanner() { const router = useRouter(); const scan = useScanNetwork(); const [timeoutSecs, setTimeoutSecs] = useState(5); const [unicastSweep, setUnicastSweep] = useState(true); - - const found = scan.data?.cameras.length; + // The refresh re-renders the dashboard on the server, which is more waiting. + const [refreshing, startRefresh] = useTransition(); + const { seconds: elapsed, start: startCounting } = useElapsedSeconds(scan.isPending); + const busy = scan.isPending || refreshing; return ( <> - {found !== undefined && ( -

- Scan found {found} camera{found === 1 ? "" : "s"} + + {scan.isPending && ( +

+ Listening for cameras that announce themselves + {unicastSweep ? ", and asking every address on this network in turn" : ""}. Cameras answer at + their own pace, so this runs for the full {timeoutSecs} seconds.

)} + {refreshing && ( +

+ Updating the list; each camera is then asked for its details and a first picture. +

+ )} + {!busy && scan.data && ( +

{summary(scan.data)}

+ )} {scan.isError && (

diff --git a/src/app/icons.test.tsx b/src/app/icons.test.tsx index d91cc89..13807db 100644 --- a/src/app/icons.test.tsx +++ b/src/app/icons.test.tsx @@ -131,10 +131,10 @@ describe("dashboard controls", () => { stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) }); renderWithQuery(); - const scan = screen.getByRole("button", { name: "Scan network" }); + const scan = screen.getByRole("button", { name: /Scan network/ }); expect(iconIn(scan)).toContain("radar"); fireEvent.click(scan); - const busy = await screen.findByRole("button", { name: "Scanning…" }); + const busy = await screen.findByRole("button", { name: /Scanning…/ }); expect(iconIn(busy)).toContain("loader-circle"); expect(busy.querySelector("svg")!.getAttribute("class")).toContain("motion-reduce:animate-none"); diff --git a/src/lib/first-run.test.ts b/src/lib/first-run.test.ts index 33f5bdf..81e3a41 100644 --- a/src/lib/first-run.test.ts +++ b/src/lib/first-run.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyEnvironment, + rememberBinDir, serverEnvironment, configFile, ensureSecrets, @@ -44,7 +45,7 @@ describe("configFile", () => { describe("readConfig / writeConfig", () => { it("remembers the folder and reads it back", async () => { - await writeConfig("/srv/clips", {}, home); + await writeConfig({ dataDir: "/srv/clips" }, {}, home); expect(await readConfig({}, home)).toEqual({ dataDir: "/srv/clips" }); }); @@ -141,7 +142,7 @@ describe("firstRun", () => { }); it("uses the remembered folder on later runs, without asking again", async () => { - await writeConfig(work, {}, home); + await writeConfig({ dataDir: work }, {}, home); const ask = vi.fn(); const result = await run({ ask }); expect(result).toMatchObject({ dataDir: work, source: "remembered", secretCreated: true }); @@ -166,7 +167,7 @@ describe("firstRun", () => { describe("applyEnvironment", () => { it("fills in the remembered folder and the stored key for this run", async () => { - await writeConfig(work, {}, home); + await writeConfig({ dataDir: work }, {}, home); await ensureSecrets(work); const env: Record = {}; await applyEnvironment(env, home); @@ -176,7 +177,7 @@ describe("applyEnvironment", () => { }); it("never overrides what the environment already says", async () => { - await writeConfig(work, {}, home); + await writeConfig({ dataDir: work }, {}, home); await ensureSecrets(work); const env = { CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" }; await applyEnvironment(env, home); @@ -192,7 +193,7 @@ describe("applyEnvironment", () => { describe("serverEnvironment", () => { it("pins the data and binary folders before the server starts elsewhere", async () => { - await writeConfig(work, {}, home); + await writeConfig({ dataDir: work }, {}, home); await ensureSecrets(work); const env: Record = {}; await serverEnvironment(env, home); @@ -209,3 +210,28 @@ describe("serverEnvironment", () => { expect(env).toMatchObject({ CAMERAS_DATA_DIR: "/srv/data", CAMERAS_BIN_DIR: "/opt/bin" }); }); }); + +describe("rememberBinDir", () => { + it("remembers where helper binaries went, so another directory still finds them", async () => { + const env: Record = {}; + expect(await rememberBinDir(env, home)).toBe(path.join(cwd, "bin")); + expect(await readConfig({}, home)).toMatchObject({ binDir: path.join(cwd, "bin") }); + + // A later command, run from somewhere else entirely, is told the same folder. + const later: Record = {}; + await applyEnvironment(later, home); + expect(later.CAMERAS_BIN_DIR).toBe(path.join(cwd, "bin")); + }); + + it("keeps the data folder it was told earlier", async () => { + await writeConfig({ dataDir: work }, {}, home); + await rememberBinDir({ CAMERAS_BIN_DIR: "/opt/bin" }, home); + expect(await readConfig({}, home)).toEqual({ dataDir: work, binDir: "/opt/bin" }); + }); + + it("prefers the environment, then what was remembered", async () => { + await writeConfig({ binDir: "/remembered" }, {}, home); + expect(await rememberBinDir({ CAMERAS_BIN_DIR: "/from-env" }, home)).toBe("/from-env"); + expect(await rememberBinDir({}, home)).toBe("/from-env"); + }); +}); diff --git a/src/lib/first-run.ts b/src/lib/first-run.ts index a32169f..b5590c8 100644 --- a/src/lib/first-run.ts +++ b/src/lib/first-run.ts @@ -26,20 +26,35 @@ export function configFile(env: Env = process.env, home = os.homedir()): string return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), CONFIG_NAME, "config.json"); } -export async function readConfig(env: Env = process.env, home = os.homedir()): Promise<{ dataDir?: string }> { +/** What the app remembers between runs: where its files and its helper binaries live. */ +export interface RememberedPaths { + dataDir?: string; + binDir?: string; +} + +export async function readConfig(env: Env = process.env, home = os.homedir()): Promise { try { const parsed: unknown = JSON.parse(await readFile(configFile(env, home), "utf8")); - const dir = (parsed as { dataDir?: unknown }).dataDir; - return typeof dir === "string" && dir ? { dataDir: dir } : {}; + const { dataDir, binDir } = parsed as RememberedPaths; + return { + ...(typeof dataDir === "string" && dataDir ? { dataDir } : {}), + ...(typeof binDir === "string" && binDir ? { binDir } : {}), + }; } catch { return {}; } } -export async function writeConfig(dir: string, env: Env = process.env, home = os.homedir()): Promise { +/** Merges into what is already remembered, so one command can't forget another's answer. */ +export async function writeConfig( + paths: RememberedPaths, + env: Env = process.env, + home = os.homedir(), +): Promise { const file = configFile(env, home); + const merged = { ...(await readConfig(env, home)), ...paths }; await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, `${JSON.stringify({ dataDir: dir }, null, 2)}\n`); + await writeFile(file, `${JSON.stringify(merged, null, 2)}\n`); } /** KEY=value lines, as written by setup and read at startup. */ @@ -128,7 +143,7 @@ export async function firstRun(options: FirstRunOptions = {}): Promise { - if (!env.CAMERAS_DATA_DIR) { - const remembered = await readConfig(env, home); - if (remembered.dataDir) env.CAMERAS_DATA_DIR = remembered.dataDir; - } + const remembered = await readConfig(env, home); + if (!env.CAMERAS_DATA_DIR && remembered.dataDir) env.CAMERAS_DATA_DIR = remembered.dataDir; + if (!env.CAMERAS_BIN_DIR && remembered.binDir) env.CAMERAS_BIN_DIR = remembered.binDir; const dir = env.CAMERAS_DATA_DIR ?? defaultDataDir(env); const stored = parseEnvFile(await readFile(path.join(dir, SECRETS_FILE), "utf8").catch(() => "")); for (const [key, value] of Object.entries(stored)) env[key] ??= value; @@ -163,3 +177,14 @@ export async function serverEnvironment(env: Env = process.env, home = os.homedi env.CAMERAS_BIN_DIR ??= defaultBinDir(env); return env; } + +/** + * Remembers where helper binaries were installed, so a later start from another directory + * still finds them (vrek iss-n8yz5cy). + */ +export async function rememberBinDir(env: Env = process.env, home = os.homedir()): Promise { + const dir = env.CAMERAS_BIN_DIR || (await readConfig(env, home)).binDir || defaultBinDir(env); + await writeConfig({ binDir: dir }, env, home); + env.CAMERAS_BIN_DIR = dir; + return dir; +} diff --git a/src/lib/onvif.ts b/src/lib/onvif.ts index 6e52bbf..9539cae 100644 --- a/src/lib/onvif.ts +++ b/src/lib/onvif.ts @@ -208,10 +208,25 @@ function unicastSweep(timeoutMs: number): Promise { }); } -export async function discoverCameras({ +/** What a scan did, so the dashboard can say more than "done" (vrek iss-jwdt0pw). */ +export interface ScanReport { + cameras: DiscoveredCamera[]; + /** How many addresses the unicast sweep probed; 0 when it was switched off. */ + addressesProbed: number; + durationMs: number; +} + +export async function discoverCameras(options: DiscoverOptions = {}): Promise { + return (await scanNetwork(options)).cameras; +} + +/** discoverCameras, with a note of what it took. */ +export async function scanNetwork({ timeoutMs = 5000, unicastSweep: sweep = true, -}: DiscoverOptions = {}): Promise { +}: DiscoverOptions = {}, now = Date.now): Promise { + const started = now(); + const addressesProbed = sweep ? localSubnetHosts().length : 0; const results = await Promise.all([ multicastProbe(timeoutMs), sweep ? unicastSweep(timeoutMs) : Promise.resolve([]), @@ -226,5 +241,5 @@ export async function discoverCameras({ merged.set(camera.urn, { ...camera }); } } - return [...merged.values()]; + return { cameras: [...merged.values()], addressesProbed, durationMs: now() - started }; }