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) <noreply@anthropic.com>
This commit is contained in:
parent
731206be8e
commit
2667967cf0
@ -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-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-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-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"}
|
||||||
|
|||||||
@ -6,7 +6,13 @@ import { access } from "node:fs/promises";
|
|||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import path from "node:path";
|
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 { installMediamtx } from "../dist/lib/mediamtx-install.js";
|
||||||
import { adminFilePath, readAdminFile } from "../dist/lib/admin-file.js";
|
import { adminFilePath, readAdminFile } from "../dist/lib/admin-file.js";
|
||||||
|
|
||||||
@ -101,7 +107,9 @@ async function admin(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function installVideo() {
|
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(
|
console.log(
|
||||||
result.status === "installed"
|
result.status === "installed"
|
||||||
? `Installed MediaMTX ${result.version} at ${result.binary} (checksum verified).`
|
? `Installed MediaMTX ${result.version} at ${result.binary} (checksum verified).`
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "onvif-dashboard",
|
"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.",
|
"description": "Web dashboard for ONVIF cameras (Hikvision, Annke): discovery, live video over WebRTC, motion recording and settings, all on your own network.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"onvif",
|
"onvif",
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const discoverCameras = vi.fn();
|
const scanNetwork = vi.fn();
|
||||||
const recordDiscovered = vi.fn();
|
const recordDiscovered = vi.fn();
|
||||||
|
|
||||||
// Access control is tested in src/app/api/access.test.ts; here requests are allowed.
|
// 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/access", () => ({ apiAccessDenied: async () => null }));
|
||||||
vi.mock("@/lib/onvif", async (importOriginal) => ({
|
vi.mock("@/lib/onvif", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@/lib/onvif")>()),
|
...(await importOriginal<typeof import("@/lib/onvif")>()),
|
||||||
discoverCameras,
|
scanNetwork,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/camera-registry", () => ({ recordDiscovered }));
|
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", () => {
|
describe("POST /api/discover", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
discoverCameras.mockReset().mockResolvedValue([camera]);
|
scanNetwork.mockReset().mockResolvedValue({ cameras: [camera], addressesProbed: 254, durationMs: 5100 });
|
||||||
recordDiscovered.mockReset().mockResolvedValue(undefined);
|
recordDiscovered.mockReset().mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scans with defaults when the body is empty, and records the result", async () => {
|
it("scans with defaults when the body is empty, and records the result", async () => {
|
||||||
const res = await post();
|
const res = await post();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.json()).toEqual({ cameras: [camera] });
|
// The report travels to the client, so it can say what the scan did (iss-jwdt0pw).
|
||||||
expect(discoverCameras).toHaveBeenCalledWith({ timeoutMs: 5000, unicastSweep: true });
|
expect(await res.json()).toEqual({ cameras: [camera], addressesProbed: 254, durationMs: 5100 });
|
||||||
|
expect(scanNetwork).toHaveBeenCalledWith({ timeoutMs: 5000, unicastSweep: true });
|
||||||
expect(recordDiscovered).toHaveBeenCalledWith([camera]);
|
expect(recordDiscovered).toHaveBeenCalledWith([camera]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes validated options through", async () => {
|
it("passes validated options through", async () => {
|
||||||
await post({ timeout: 2000, unicastSweep: false });
|
await post({ timeout: 2000, unicastSweep: false });
|
||||||
expect(discoverCameras).toHaveBeenCalledWith({ timeoutMs: 2000, unicastSweep: false });
|
expect(scanNetwork).toHaveBeenCalledWith({ timeoutMs: 2000, unicastSweep: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@ -53,7 +54,7 @@ describe("POST /api/discover", () => {
|
|||||||
const res = await post(body);
|
const res = await post(body);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(await res.json()).toEqual({ error: "Invalid scan options" });
|
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.
|
// 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'";
|
const secret = "EISDIR: illegal operation on a directory, open '/secret/path/cameras.json'";
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["discovery throws", () => discoverCameras.mockRejectedValue(new Error(secret))],
|
["discovery throws", () => scanNetwork.mockRejectedValue(new Error(secret))],
|
||||||
["recording results throws", () => recordDiscovered.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) => {
|
])("returns a generic 500 when %s", async (_label, arrange) => {
|
||||||
arrange();
|
arrange();
|
||||||
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { apiAccessDenied } from "@/lib/access";
|
import { apiAccessDenied } from "@/lib/access";
|
||||||
import { recordDiscovered } from "@/lib/camera-registry";
|
import { recordDiscovered } from "@/lib/camera-registry";
|
||||||
import { discoverCameras, discoverRequestSchema } from "@/lib/onvif";
|
import { discoverRequestSchema, scanNetwork } from "@/lib/onvif";
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const denied = await apiAccessDenied();
|
const denied = await apiAccessDenied();
|
||||||
@ -14,9 +14,9 @@ export async function POST(request: Request) {
|
|||||||
const { timeout, unicastSweep } = parsed.data;
|
const { timeout, unicastSweep } = parsed.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cameras = await discoverCameras({ timeoutMs: timeout, unicastSweep });
|
const report = await scanNetwork({ timeoutMs: timeout, unicastSweep });
|
||||||
await recordDiscovered(cameras);
|
await recordDiscovered(report.cameras);
|
||||||
return Response.json({ cameras });
|
return Response.json(report);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// OS and socket errors can carry interface names and addresses; keep them in the server log.
|
// OS and socket errors can carry interface names and addresses; keep them in the server log.
|
||||||
console.error("Network scan failed", err);
|
console.error("Network scan failed", err);
|
||||||
|
|||||||
@ -139,8 +139,14 @@ export interface ScanOptions {
|
|||||||
unicastSweep: boolean;
|
unicastSweep: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScanReport {
|
||||||
|
cameras: DiscoveredCamera[];
|
||||||
|
addressesProbed: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function useScanNetwork() {
|
export function useScanNetwork() {
|
||||||
return useMutation<{ cameras: DiscoveredCamera[] }, CameraProblem, ScanOptions>({
|
return useMutation<ScanReport, CameraProblem, ScanOptions>({
|
||||||
mutationFn: ({ timeoutSecs, unicastSweep }) =>
|
mutationFn: ({ timeoutSecs, unicastSweep }) =>
|
||||||
requestJson("/api/discover", {
|
requestJson("/api/discover", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@ -13,23 +13,50 @@ const body = (fetchMock: ReturnType<typeof stubFetch>) =>
|
|||||||
describe("CameraScanner", () => {
|
describe("CameraScanner", () => {
|
||||||
beforeEach(() => refresh.mockReset());
|
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({
|
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(<CameraScanner />);
|
renderWithQuery(<CameraScanner />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
fireEvent.click(screen.getByRole("button", { name: /Scan network/ }));
|
||||||
expect(await screen.findByText("Scan found 2 cameras")).toBeTruthy();
|
expect(await screen.findByText("Found 2 cameras in 5s after checking 4,094 addresses.")).toBeTruthy();
|
||||||
expect(refresh).toHaveBeenCalledTimes(1);
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
expect(body(fetchMock)).toEqual({ timeout: 5000, unicastSweep: true });
|
expect(body(fetchMock)).toEqual({ timeout: 5000, unicastSweep: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the singular for one camera", async () => {
|
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" }] }) });
|
stubFetch({
|
||||||
|
"POST /api/discover": () => json({ cameras: [{ id: "a" }], addressesProbed: 0, durationMs: 900 }),
|
||||||
|
});
|
||||||
renderWithQuery(<CameraScanner />);
|
renderWithQuery(<CameraScanner />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
fireEvent.click(screen.getByRole("button", { name: /Scan network/ }));
|
||||||
expect(await screen.findByText("Scan found 1 camera")).toBeTruthy();
|
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(<CameraScanner />);
|
||||||
|
|
||||||
|
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(<CameraScanner />);
|
||||||
|
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 () => {
|
it("disables the button while scanning", async () => {
|
||||||
@ -37,11 +64,12 @@ describe("CameraScanner", () => {
|
|||||||
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });
|
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });
|
||||||
renderWithQuery(<CameraScanner />);
|
renderWithQuery(<CameraScanner />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
fireEvent.click(screen.getByRole("button", { name: /Scan network/ }));
|
||||||
const button = await screen.findByRole<HTMLButtonElement>("button", { name: "Scanning…" });
|
const button = await screen.findByRole<HTMLButtonElement>("button", { name: /Scanning…/ });
|
||||||
expect(button.disabled).toBe(true);
|
expect(button.disabled).toBe(true);
|
||||||
finish(json({ cameras: [] }));
|
expect(screen.getByLabelText("Unicast sweep of local subnet")).toHaveProperty("disabled", true);
|
||||||
await screen.findByRole("button", { name: "Scan network" });
|
finish(json({ cameras: [], addressesProbed: 0, durationMs: 100 }));
|
||||||
|
await screen.findByRole("button", { name: /Scan network/ });
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@ -50,12 +78,12 @@ describe("CameraScanner", () => {
|
|||||||
["a value below 1 s", "0.2", 1000],
|
["a value below 1 s", "0.2", 1000],
|
||||||
["an empty field", "", 5000],
|
["an empty field", "", 5000],
|
||||||
])("sends the timeout clamped to 1–30 s for %s", async (_label, typed, expected) => {
|
])("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(<CameraScanner />);
|
renderWithQuery(<CameraScanner />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: typed } });
|
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: typed } });
|
||||||
fireEvent.click(screen.getByRole("checkbox"));
|
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));
|
await waitFor(() => expect(calls(fetchMock, "POST /api/discover")).toHaveLength(1));
|
||||||
expect(body(fetchMock)).toEqual({ timeout: expected, unicastSweep: false });
|
expect(body(fetchMock)).toEqual({ timeout: expected, unicastSweep: false });
|
||||||
});
|
});
|
||||||
@ -64,7 +92,7 @@ describe("CameraScanner", () => {
|
|||||||
stubFetch({ "POST /api/discover": () => json({ error: "Network scan failed" }, 500) });
|
stubFetch({ "POST /api/discover": () => json({ error: "Network scan failed" }, 500) });
|
||||||
renderWithQuery(<CameraScanner />);
|
renderWithQuery(<CameraScanner />);
|
||||||
|
|
||||||
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(await screen.findByText("Error: Network scan failed")).toBeTruthy();
|
||||||
expect(refresh).not.toHaveBeenCalled();
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -73,7 +101,7 @@ describe("CameraScanner", () => {
|
|||||||
stubFetch({ "POST /api/discover": () => new Response("oops", { status: 503 }) });
|
stubFetch({ "POST /api/discover": () => new Response("oops", { status: 503 }) });
|
||||||
renderWithQuery(<CameraScanner />);
|
renderWithQuery(<CameraScanner />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
fireEvent.click(screen.getByRole("button", { name: /Scan network/ }));
|
||||||
expect(await screen.findByText("Error: HTTP 503")).toBeTruthy();
|
expect(await screen.findByText("Error: HTTP 503")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,36 +2,79 @@
|
|||||||
|
|
||||||
import { CircleAlert, LoaderCircle, Radar } from "lucide-react";
|
import { CircleAlert, LoaderCircle, Radar } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { useScanNetwork } from "./camera-queries";
|
import { useScanNetwork, type ScanReport } from "./camera-queries";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a network scan. The scan writes the server-side registry, so afterwards the
|
* 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.
|
* 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() {
|
export default function CameraScanner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const scan = useScanNetwork();
|
const scan = useScanNetwork();
|
||||||
const [timeoutSecs, setTimeoutSecs] = useState(5);
|
const [timeoutSecs, setTimeoutSecs] = useState(5);
|
||||||
const [unicastSweep, setUnicastSweep] = useState(true);
|
const [unicastSweep, setUnicastSweep] = useState(true);
|
||||||
|
// The refresh re-renders the dashboard on the server, which is more waiting.
|
||||||
const found = scan.data?.cameras.length;
|
const [refreshing, startRefresh] = useTransition();
|
||||||
|
const { seconds: elapsed, start: startCounting } = useElapsedSeconds(scan.isPending);
|
||||||
|
const busy = scan.isPending || refreshing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
scan.mutate({ timeoutSecs, unicastSweep }, { onSuccess: () => router.refresh() })
|
startCounting();
|
||||||
}
|
scan.mutate(
|
||||||
disabled={scan.isPending}
|
{ timeoutSecs, unicastSweep },
|
||||||
|
{ onSuccess: () => startRefresh(() => router.refresh()) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
className="inline-flex items-center gap-2 rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||||
>
|
>
|
||||||
{scan.isPending ? (
|
{busy ? (
|
||||||
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
|
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
|
||||||
) : (
|
) : (
|
||||||
<Radar aria-hidden className="size-4" />
|
<Radar aria-hidden className="size-4" />
|
||||||
)}
|
)}
|
||||||
{scan.isPending ? "Scanning…" : "Scan network"}
|
{scan.isPending ? `Scanning… ${elapsed}s of ${timeoutSecs}s` : refreshing ? "Updating the list…" : "Scan network"}
|
||||||
</button>
|
</button>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
Timeout
|
Timeout
|
||||||
@ -41,6 +84,7 @@ export default function CameraScanner() {
|
|||||||
max={30}
|
max={30}
|
||||||
value={timeoutSecs}
|
value={timeoutSecs}
|
||||||
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
|
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
|
||||||
|
disabled={busy}
|
||||||
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
||||||
/>
|
/>
|
||||||
s
|
s
|
||||||
@ -50,14 +94,26 @@ export default function CameraScanner() {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={unicastSweep}
|
checked={unicastSweep}
|
||||||
onChange={(e) => setUnicastSweep(e.target.checked)}
|
onChange={(e) => setUnicastSweep(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
Unicast sweep of local subnet
|
Unicast sweep of local subnet
|
||||||
</label>
|
</label>
|
||||||
{found !== undefined && (
|
|
||||||
<p className="w-full text-sm text-zinc-600 dark:text-zinc-400">
|
{scan.isPending && (
|
||||||
Scan found {found} camera{found === 1 ? "" : "s"}
|
<p role="status" className="w-full text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
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.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{refreshing && (
|
||||||
|
<p role="status" className="w-full text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
Updating the list; each camera is then asked for its details and a first picture.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!busy && scan.data && (
|
||||||
|
<p className="w-full text-sm text-zinc-600 dark:text-zinc-400">{summary(scan.data)}</p>
|
||||||
|
)}
|
||||||
{scan.isError && (
|
{scan.isError && (
|
||||||
<p className="flex w-full items-center gap-1.5 text-sm text-red-600">
|
<p className="flex w-full items-center gap-1.5 text-sm text-red-600">
|
||||||
<CircleAlert aria-hidden className="size-4 shrink-0" />
|
<CircleAlert aria-hidden className="size-4 shrink-0" />
|
||||||
|
|||||||
@ -131,10 +131,10 @@ describe("dashboard controls", () => {
|
|||||||
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });
|
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });
|
||||||
renderWithQuery(<CameraScanner />);
|
renderWithQuery(<CameraScanner />);
|
||||||
|
|
||||||
const scan = screen.getByRole("button", { name: "Scan network" });
|
const scan = screen.getByRole("button", { name: /Scan network/ });
|
||||||
expect(iconIn(scan)).toContain("radar");
|
expect(iconIn(scan)).toContain("radar");
|
||||||
fireEvent.click(scan);
|
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(iconIn(busy)).toContain("loader-circle");
|
||||||
expect(busy.querySelector("svg")!.getAttribute("class")).toContain("motion-reduce:animate-none");
|
expect(busy.querySelector("svg")!.getAttribute("class")).toContain("motion-reduce:animate-none");
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
applyEnvironment,
|
applyEnvironment,
|
||||||
|
rememberBinDir,
|
||||||
serverEnvironment,
|
serverEnvironment,
|
||||||
configFile,
|
configFile,
|
||||||
ensureSecrets,
|
ensureSecrets,
|
||||||
@ -44,7 +45,7 @@ describe("configFile", () => {
|
|||||||
|
|
||||||
describe("readConfig / writeConfig", () => {
|
describe("readConfig / writeConfig", () => {
|
||||||
it("remembers the folder and reads it back", async () => {
|
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" });
|
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 () => {
|
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 ask = vi.fn();
|
||||||
const result = await run({ ask });
|
const result = await run({ ask });
|
||||||
expect(result).toMatchObject({ dataDir: work, source: "remembered", secretCreated: true });
|
expect(result).toMatchObject({ dataDir: work, source: "remembered", secretCreated: true });
|
||||||
@ -166,7 +167,7 @@ describe("firstRun", () => {
|
|||||||
|
|
||||||
describe("applyEnvironment", () => {
|
describe("applyEnvironment", () => {
|
||||||
it("fills in the remembered folder and the stored key for this run", async () => {
|
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);
|
await ensureSecrets(work);
|
||||||
const env: Record<string, string | undefined> = {};
|
const env: Record<string, string | undefined> = {};
|
||||||
await applyEnvironment(env, home);
|
await applyEnvironment(env, home);
|
||||||
@ -176,7 +177,7 @@ describe("applyEnvironment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never overrides what the environment already says", async () => {
|
it("never overrides what the environment already says", async () => {
|
||||||
await writeConfig(work, {}, home);
|
await writeConfig({ dataDir: work }, {}, home);
|
||||||
await ensureSecrets(work);
|
await ensureSecrets(work);
|
||||||
const env = { CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" };
|
const env = { CAMERAS_DATA_DIR: "/elsewhere", CAMERA_CREDENTIALS_KEY: "mine" };
|
||||||
await applyEnvironment(env, home);
|
await applyEnvironment(env, home);
|
||||||
@ -192,7 +193,7 @@ describe("applyEnvironment", () => {
|
|||||||
|
|
||||||
describe("serverEnvironment", () => {
|
describe("serverEnvironment", () => {
|
||||||
it("pins the data and binary folders before the server starts elsewhere", async () => {
|
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);
|
await ensureSecrets(work);
|
||||||
const env: Record<string, string | undefined> = {};
|
const env: Record<string, string | undefined> = {};
|
||||||
await serverEnvironment(env, home);
|
await serverEnvironment(env, home);
|
||||||
@ -209,3 +210,28 @@ describe("serverEnvironment", () => {
|
|||||||
expect(env).toMatchObject({ CAMERAS_DATA_DIR: "/srv/data", CAMERAS_BIN_DIR: "/opt/bin" });
|
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<string, string | undefined> = {};
|
||||||
|
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<string, string | undefined> = {};
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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");
|
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<RememberedPaths> {
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(await readFile(configFile(env, home), "utf8"));
|
const parsed: unknown = JSON.parse(await readFile(configFile(env, home), "utf8"));
|
||||||
const dir = (parsed as { dataDir?: unknown }).dataDir;
|
const { dataDir, binDir } = parsed as RememberedPaths;
|
||||||
return typeof dir === "string" && dir ? { dataDir: dir } : {};
|
return {
|
||||||
|
...(typeof dataDir === "string" && dataDir ? { dataDir } : {}),
|
||||||
|
...(typeof binDir === "string" && binDir ? { binDir } : {}),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeConfig(dir: string, env: Env = process.env, home = os.homedir()): Promise<void> {
|
/** 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<void> {
|
||||||
const file = configFile(env, home);
|
const file = configFile(env, home);
|
||||||
|
const merged = { ...(await readConfig(env, home)), ...paths };
|
||||||
await mkdir(path.dirname(file), { recursive: true });
|
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. */
|
/** KEY=value lines, as written by setup and read at startup. */
|
||||||
@ -128,7 +143,7 @@ export async function firstRun(options: FirstRunOptions = {}): Promise<FirstRunR
|
|||||||
source = "default";
|
source = "default";
|
||||||
log(`[setup] No terminal to ask; keeping data in ${dataDir}. Set CAMERAS_DATA_DIR to change it.`);
|
log(`[setup] No terminal to ask; keeping data in ${dataDir}. Set CAMERAS_DATA_DIR to change it.`);
|
||||||
}
|
}
|
||||||
await writeConfig(dataDir, env, home);
|
await writeConfig({ dataDir }, env, home);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||||
@ -143,10 +158,9 @@ export async function firstRun(options: FirstRunOptions = {}): Promise<FirstRunR
|
|||||||
|
|
||||||
/** Puts the remembered folder and the stored key into the environment for this run. */
|
/** Puts the remembered folder and the stored key into the environment for this run. */
|
||||||
export async function applyEnvironment(env: Env = process.env, home = os.homedir()): Promise<void> {
|
export async function applyEnvironment(env: Env = process.env, home = os.homedir()): Promise<void> {
|
||||||
if (!env.CAMERAS_DATA_DIR) {
|
const remembered = await readConfig(env, home);
|
||||||
const remembered = await readConfig(env, home);
|
if (!env.CAMERAS_DATA_DIR && remembered.dataDir) env.CAMERAS_DATA_DIR = remembered.dataDir;
|
||||||
if (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 dir = env.CAMERAS_DATA_DIR ?? defaultDataDir(env);
|
||||||
const stored = parseEnvFile(await readFile(path.join(dir, SECRETS_FILE), "utf8").catch(() => ""));
|
const stored = parseEnvFile(await readFile(path.join(dir, SECRETS_FILE), "utf8").catch(() => ""));
|
||||||
for (const [key, value] of Object.entries(stored)) env[key] ??= value;
|
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);
|
env.CAMERAS_BIN_DIR ??= defaultBinDir(env);
|
||||||
return 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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -208,10 +208,25 @@ function unicastSweep(timeoutMs: number): Promise<DiscoveredCamera[]> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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<DiscoveredCamera[]> {
|
||||||
|
return (await scanNetwork(options)).cameras;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** discoverCameras, with a note of what it took. */
|
||||||
|
export async function scanNetwork({
|
||||||
timeoutMs = 5000,
|
timeoutMs = 5000,
|
||||||
unicastSweep: sweep = true,
|
unicastSweep: sweep = true,
|
||||||
}: DiscoverOptions = {}): Promise<DiscoveredCamera[]> {
|
}: DiscoverOptions = {}, now = Date.now): Promise<ScanReport> {
|
||||||
|
const started = now();
|
||||||
|
const addressesProbed = sweep ? localSubnetHosts().length : 0;
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
multicastProbe(timeoutMs),
|
multicastProbe(timeoutMs),
|
||||||
sweep ? unicastSweep(timeoutMs) : Promise.resolve([]),
|
sweep ? unicastSweep(timeoutMs) : Promise.resolve([]),
|
||||||
@ -226,5 +241,5 @@ export async function discoverCameras({
|
|||||||
merged.set(camera.urn, { ...camera });
|
merged.set(camera.urn, { ...camera });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...merged.values()];
|
return { cameras: [...merged.values()], addressesProbed, durationMs: now() - started };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user