Move dashboard client onto React Query hooks
Adds src/app/camera-queries.ts: React Query hooks over our own routes, with each camera's queries keyed ['camera', id]. The camera card, snapshot polling, login form and network scan use these hooks instead of hand-rolled fetch/useEffect state (vrek iss-2fm6x2y, iss-ksxmctm, iss-m032zwq, iss-8hfq2y2). - Snapshot polling pauses in hidden tabs, never overlaps a slow frame, and stops after a failure until Retry. Each frame's object URL is created and revoked in one effect, so none leak under Strict Mode. - Saving or forgetting a login resets only that camera's queries. - New "Forget saved login" action, shown for stored logins. - Fix: a scan timeout typed below 1 s now clamps to 1 s instead of falling back to 5 s. Adds jsdom component tests (test/dom.tsx helpers): 93 tests, line coverage 54.3%. Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
97a6b59cfe
commit
870b4cd2af
@ -199,3 +199,25 @@
|
||||
{"id":"evt-zb67vbj9gq3f","type":"edge.added","subject":"iss-zjpc22k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-zjpc22k","to":"gol-9zxah3p"},"at":"2026-09-19T13:48:34.121Z","parents":["evt-b5118ev8dnhf"],"hash":"47a2a3f02b139eab3f36583eb856278791a46e9e6035cad834de35f8aeca3f49"}
|
||||
{"id":"evt-9jqc4c9akpvd","type":"edge.added","subject":"iss-zjpc22k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-zjpc22k","to":"vitest.config.mts"},"at":"2026-09-19T13:48:34.122Z","parents":["evt-zb67vbj9gq3f"],"hash":"2dce8ab9c28c069b400e6bfbcbb1859ea66e49e72c4c36dab1e7c1386c2cd67e"}
|
||||
{"id":"evt-tgnhg3bzctxe","type":"edge.added","subject":"iss-zjpc22k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-zjpc22k","to":"area:testing"},"at":"2026-09-19T13:48:34.123Z","parents":["evt-9jqc4c9akpvd"],"hash":"f5502f9006c88d881121d0b5ea5750d9a179ac754b1d53aab5469bb2e5d9ebaf"}
|
||||
{"id":"evt-0ep7e88wpm1m","type":"node.created","subject":"ver-k6ndd19","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Scan runs through useScanNetwork (useMutation); on success it calls router.refresh() and shows the count; on failure it shows the error and does not refresh; the button is disabled while pending; the timeout is clamped to 1–30 s with an empty field meaning 5 s.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:30.768Z","parents":["evt-tgnhg3bzctxe"],"hash":"228254a84db314e5d0bcdccc8cf513bc5a457734f3cc8b3df2aad552de7ba5a7"}
|
||||
{"id":"evt-gy197h670rnw","type":"edge.added","subject":"ver-k6ndd19","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-k6ndd19","to":"iss-2fm6x2y"},"at":"2026-09-19T13:58:30.771Z","parents":["evt-0ep7e88wpm1m"],"hash":"43d375d7ed15df69443fa05d8153467b55d16b7ced4d4ea339eee7b5c05188a5"}
|
||||
{"id":"evt-anhn0b0z57dq","type":"verification.recorded","subject":"ver-k6ndd19","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-scanner.test.tsx (9 tests), `npx vitest run src/app/camera-scanner.test.tsx`, 2026-09-19. The test caught a real bug, fixed: a typed timeout under 1 s rounded to 0 and fell back to 5 s. Not exercised in a real browser."},"at":"2026-09-19T13:58:30.772Z","parents":["evt-gy197h670rnw"],"hash":"e51323701717157533b89c853bda296e5a91d3b71231980f2414b0a813714be1"}
|
||||
{"id":"evt-s3m83caq68et","type":"node.created","subject":"ver-5f7rnb2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"CameraCard gets info via useCameraInfo (['camera', id, 'info']), with no useState/useEffect/attempt counter; auth → login form, inactive → setup panel, other failures retried once then shown with Retry; Retry resets the camera's queries.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:33.140Z","parents":["evt-anhn0b0z57dq"],"hash":"bb78b9114fa6856fd437db70a91590eda976e7814e2d6937f4a825089aafbe89"}
|
||||
{"id":"evt-6by9j3pw9thh","type":"edge.added","subject":"ver-5f7rnb2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-5f7rnb2","to":"iss-ksxmctm"},"at":"2026-09-19T13:58:33.142Z","parents":["evt-s3m83caq68et"],"hash":"29bb2a1c1e5361c2379e3e3916e27ddeaf2bd2d6459b67a209587286e0cd1124"}
|
||||
{"id":"evt-7628nyyf5hnp","type":"verification.recorded","subject":"ver-5f7rnb2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-card.test.tsx: 'retries a plain info failure once' (exactly 2 info fetches, 0 snapshot), 'shows setup steps…', 'asks for a login…', 'reports a network error…'. Hooks are in src/app/camera-queries.ts. 2026-09-19; not exercised in a real browser."},"at":"2026-09-19T13:58:33.143Z","parents":["evt-6by9j3pw9thh"],"hash":"335bc8d637583724617a6432fad5176a0c1cf22dba19dee1921fedf3efe4ba39"}
|
||||
{"id":"evt-cbz0k7fvr9ez","type":"node.created","subject":"ver-e7k77p6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Snapshots poll via useCameraSnapshot (refetchInterval, only once info has loaded); every object URL is revoked, including on unmount; after a failed frame, polling stops with no further requests until Retry.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:36.163Z","parents":["evt-7628nyyf5hnp"],"hash":"e9417f97d166f402922124c794a56a667daf2d5685b2cc8fcce8c0b9f4b73c23"}
|
||||
{"id":"evt-g0fcfn3fav15","type":"edge.added","subject":"ver-e7k77p6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-e7k77p6","to":"iss-m032zwq"},"at":"2026-09-19T13:58:36.164Z","parents":["evt-cbz0k7fvr9ez"],"hash":"f05e75428f603fe9d9e3d4ec616212532d1e75e1f13f16f2eea4ea68f39c8388"}
|
||||
{"id":"evt-3ezj7nyaet8c","type":"verification.recorded","subject":"ver-e7k77p6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-card.test.tsx: 'frees every frame's object URL, including on unmount' (created ≥4, revoked set == created set) and 'stops polling after a failed frame' (0 extra requests over 150 ms at a 20 ms interval, then resumes after Retry). Interval refetches reuse an in-flight request (query-core queryObserver #executeFetch has no cancelRefetch), so a slow camera doesn't pile up requests. Object URLs are created and revoked in one effect, so they're safe under Strict Mode. 2026-09-19; the 'few minutes in a real browser' memory check was not run."},"at":"2026-09-19T13:58:36.165Z","parents":["evt-g0fcfn3fav15"],"hash":"e03c0fe1e2cb3aa4566b5d8b8ba37f18bd45a2f9f7b01cd152733972744893d9"}
|
||||
{"id":"evt-q24pzht5swew","type":"node.created","subject":"ver-9z27bka","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The credentials GET is a useQuery (username prefill, never a password); PUT and DELETE are useMutations whose success resets only that camera's ['camera', id] queries; a rejected login shows as a form error.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T13:58:39.168Z","parents":["evt-3ezj7nyaet8c"],"hash":"f273b46aa473eb2403e6a8f5cb9d917b7504443207d8f39a70308bfb92647222"}
|
||||
{"id":"evt-b83nqk81tsyh","type":"edge.added","subject":"ver-9z27bka","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-9z27bka","to":"iss-8hfq2y2"},"at":"2026-09-19T13:58:39.170Z","parents":["evt-q24pzht5swew"],"hash":"97ba093b1d98a4a277e711e86a7a2553ee23d6c57823b98b709914ff1359126f"}
|
||||
{"id":"evt-dwsp2442bbq6","type":"verification.recorded","subject":"ver-9z27bka","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/camera-card.test.tsx 'login' block (6 tests: prefill, typed username wins, rejected login, other failure, cancel, forget saved login via DELETE) and 'two cameras' (saving camera A refetches A's info, while B's info stays at 1 fetch). resetQueries is used rather than invalidateQueries so that error state clears. Adds a 'Forget saved login' button, shown only for stored logins. 2026-09-19; not exercised in a real browser."},"at":"2026-09-19T13:58:39.171Z","parents":["evt-b83nqk81tsyh"],"hash":"9fbc058401774aeb723683396a65a69e43b0cbbb3b2ecb8dd45dbaf7c7905786"}
|
||||
{"id":"evt-edzjpeex02wj","type":"node.created","subject":"mea-s1kf91q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Line coverage of src/** (excluding page.tsx, *.d.ts, tests)","body":"`npm run coverage`, 2026-09-19, after the React Query migration and its component tests: 235/433 lines, 93 tests. At 100%: camera-card, camera-queries, camera-scanner, refresh-rate-select, refresh-rate, discover route, camera-route, credential-store. Remaining gaps: camera.ts 13.5%, onvif.ts 7.3%, the info/snapshot/credentials routes 0%, layout and providers 0%.","status":"recorded","owner":null,"attrs":{"value":54.27,"applies_at":"2026-09-19T13:58:41.458Z"}},"at":"2026-09-19T13:58:41.458Z","parents":["evt-dwsp2442bbq6"],"hash":"d758ed6a96554f9d760f0214fc04b50a34ae97bed44cdbdcc1570fc654d42d21"}
|
||||
{"id":"evt-0wdpqz1n37pf","type":"edge.added","subject":"mea-s1kf91q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-s1kf91q","to":"gol-9zxah3p"},"at":"2026-09-19T13:58:41.459Z","parents":["evt-edzjpeex02wj"],"hash":"c5b74cc09fb476bed847f38fbd2a2dc5d25a10d92f4dfc00da775175a9f4d24b"}
|
||||
{"id":"evt-4zw0z9bpe55j","type":"node.status_changed","subject":"iss-2fm6x2y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:44.403Z","parents":["evt-0wdpqz1n37pf"],"hash":"89ba0614e63465ee0a196031731d6d66fbd9510e1b99af04ca4b02f51a686557"}
|
||||
{"id":"evt-4v0jw11be2tj","type":"node.status_changed","subject":"iss-ksxmctm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:45.459Z","parents":["evt-4zw0z9bpe55j"],"hash":"a112adef63186440af148e6b8f448284a136e185f04c0d4285603357e756fec2"}
|
||||
{"id":"evt-0v8z82ap6vw8","type":"node.status_changed","subject":"iss-m032zwq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:46.508Z","parents":["evt-4v0jw11be2tj"],"hash":"2a1d315573688aa3502db53ecc5a39e073e4a9a6820861e01d710cf30db511b1"}
|
||||
{"id":"evt-gdbnzswzw52f","type":"node.status_changed","subject":"iss-8hfq2y2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T13:58:47.573Z","parents":["evt-0v8z82ap6vw8"],"hash":"967a983d4444f32f6624a17b0cf15d2f520bf62e88b6a4fca57dd23a0dcd8904"}
|
||||
{"id":"evt-5q5femhczhwy","type":"node.updated","subject":"iss-nc5w0j8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"title":"Component tests for providers and layout","body":"Mostly done alongside the React Query migration: camera-card, camera-queries, camera-scanner and refresh-rate-select are at 100% line coverage (test/dom.tsx has the render and fetch helpers). Remaining: providers.tsx (QueryClient defaults: staleTime 0, retry 1) and layout.tsx (mock next/font/google; check it wraps children in Providers)."}},"at":"2026-09-19T13:58:48.649Z","parents":["evt-gdbnzswzw52f"],"hash":"86128137c9d72e962190179549cc17c4970115c46958d76c06185693736ad65c"}
|
||||
{"id":"evt-f6skgy2z97a7","type":"node.created","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser against real cameras, 'Change login' and 'Forget saved login' work end to end.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T14:01:24.882Z","parents":["evt-5q5femhczhwy"],"hash":"c6c5f6af9cf551e1cfeae733656cd853d9d712dccab3da0914b87198a99c90a8"}
|
||||
{"id":"evt-2xx680y4h043","type":"edge.added","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-h8wgh7r","to":"iss-8hfq2y2"},"at":"2026-09-19T14:01:24.884Z","parents":["evt-f6skgy2z97a7"],"hash":"e276e752fb4db6dff1c9a931f93047d819a47f367298e1417c814aa43d89804a"}
|
||||
{"id":"evt-e2r4bptapxrq","type":"verification.recorded","subject":"ver-h8wgh7r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual test by the user (Michael Mainguy) on 2026-09-19: both flows reported working."},"at":"2026-09-19T14:01:24.885Z","parents":["evt-2xx680y4h043"],"hash":"edcb6d01b6770914dc084b7542709bcbc8eba7129391cf6fc4a548191e7e05cd"}
|
||||
|
||||
265
src/app/camera-card.test.tsx
Normal file
265
src/app/camera-card.test.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
// @vitest-environment jsdom
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CameraSummary } from "@/lib/camera-registry";
|
||||
import { calls, json, renderWithQuery, stubFetch } from "../../test/dom";
|
||||
import CameraCard from "./camera-card";
|
||||
|
||||
const cam: CameraSummary = {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
host: "192.168.1.10",
|
||||
port: 8080,
|
||||
name: "Porch",
|
||||
location: "Front door",
|
||||
lastSeen: "2026-09-19T00:00:00.000Z",
|
||||
};
|
||||
const base = `/api/cameras/${cam.id}`;
|
||||
|
||||
const info = {
|
||||
manufacturer: "Hikvision",
|
||||
model: "DS-2CD",
|
||||
firmwareVersion: "V5.7",
|
||||
profiles: [{ token: "p1", name: "Main", encoding: "H264", width: 1920, height: 1080, fps: 25 }],
|
||||
};
|
||||
const frame = () => new Response(new Blob(["jpeg"], { type: "image/jpeg" }));
|
||||
const noLogin = { source: "none", username: null, hasPassword: false };
|
||||
|
||||
// Count object URLs so leaks show up. Spies (restored by restoreMocks) rather than deletes,
|
||||
// because a late effect from the previous test may still run during its cleanup.
|
||||
let created: string[];
|
||||
let revoked: string[];
|
||||
beforeEach(() => {
|
||||
created = [];
|
||||
revoked = [];
|
||||
let n = 0;
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation(() => {
|
||||
const url = `blob:frame-${++n}`;
|
||||
created.push(url);
|
||||
return url;
|
||||
});
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation((url: string) => void revoked.push(url));
|
||||
});
|
||||
|
||||
describe("CameraCard", () => {
|
||||
it("shows the camera's details, profiles and live frame", async () => {
|
||||
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
expect(screen.getByText("Porch")).toBeTruthy();
|
||||
expect(screen.getByText("192.168.1.10:8080")).toBeTruthy();
|
||||
expect(screen.getByText("Front door")).toBeTruthy();
|
||||
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
|
||||
expect(screen.getByText("1920×1080")).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("img", { name: "Porch" }).getAttribute("src")).toMatch(/^blob:/),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the model, then 'Unnamed camera', for the title", async () => {
|
||||
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
|
||||
renderWithQuery(<CameraCard cam={{ ...cam, name: undefined }} intervalMs={1000} />);
|
||||
expect(screen.getByText("Unnamed camera")).toBeTruthy();
|
||||
expect(await screen.findByText("DS-2CD")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("frees every frame's object URL, including on unmount", async () => {
|
||||
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
|
||||
const { unmount } = renderWithQuery(<CameraCard cam={cam} intervalMs={20} />);
|
||||
|
||||
await waitFor(() => expect(created.length).toBeGreaterThanOrEqual(4));
|
||||
unmount();
|
||||
expect(revoked.sort()).toEqual([...created].sort());
|
||||
});
|
||||
|
||||
it("stops polling after a failed frame and shows Retry", async () => {
|
||||
let fail = false;
|
||||
const fetchMock = stubFetch({
|
||||
[`GET ${base}/info`]: () => json(info),
|
||||
[`GET ${base}/snapshot`]: () => (fail ? json({ error: "Camera timed out" }, 502) : frame()),
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={20} />);
|
||||
await waitFor(() => expect(created.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
fail = true;
|
||||
expect(await screen.findByText("Stopped: Camera timed out")).toBeTruthy();
|
||||
const afterFailure = calls(fetchMock, `GET ${base}/snapshot`).length;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
// Not retried at all while stopped: an offline camera isn't hammered.
|
||||
expect(calls(fetchMock, `GET ${base}/snapshot`).length).toBe(afterFailure);
|
||||
|
||||
fail = false;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
await waitFor(() => expect(screen.queryByText(/Stopped:/)).toBeNull());
|
||||
await waitFor(() =>
|
||||
expect(calls(fetchMock, `GET ${base}/snapshot`).length).toBeGreaterThan(afterFailure),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries a plain info failure once, then offers Retry", async () => {
|
||||
const fetchMock = stubFetch({
|
||||
[`GET ${base}/info`]: () => json({ error: "Camera unreachable" }, 502),
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
expect(await screen.findByText("Stopped: Camera unreachable")).toBeTruthy();
|
||||
expect(calls(fetchMock, `GET ${base}/info`)).toHaveLength(2);
|
||||
expect(calls(fetchMock, `GET ${base}/snapshot`)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports a network error from fetch itself", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
expect(await screen.findByText("Stopped: Failed to fetch")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows setup steps for an inactive camera, and checks again on request", async () => {
|
||||
let active = false;
|
||||
stubFetch({
|
||||
[`GET ${base}/info`]: () =>
|
||||
active ? json(info) : json({ error: "not activated", code: "inactive" }, 409),
|
||||
[`GET ${base}/snapshot`]: frame,
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
expect(await screen.findByText("Setup required: camera not activated")).toBeTruthy();
|
||||
active = true;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Check again" }));
|
||||
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
|
||||
expect(screen.queryByText(/Setup required/)).toBeNull();
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("asks for a login when the camera rejects it, then recovers after saving", async () => {
|
||||
let loggedIn = false;
|
||||
const fetchMock = stubFetch({
|
||||
[`GET ${base}/info`]: () =>
|
||||
loggedIn ? json(info) : json({ error: "Wrong login", code: "auth" }, 401),
|
||||
[`GET ${base}/snapshot`]: frame,
|
||||
[`GET ${base}/credentials`]: () => json({ ...noLogin, username: "saved-user" }),
|
||||
[`PUT ${base}/credentials`]: () => {
|
||||
loggedIn = true;
|
||||
return json({ source: "stored", username: "saved-user", hasPassword: true });
|
||||
},
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
expect(await screen.findByText("Wrong login")).toBeTruthy();
|
||||
// Prefilled with the saved username; the password never comes back.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByPlaceholderText<HTMLInputElement>("Username").value).toBe("saved-user"),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Password"), { target: { value: "pw" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(await screen.findByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
|
||||
expect(screen.queryByText("Camera login")).toBeNull();
|
||||
const [, init] = calls(fetchMock, `PUT ${base}/credentials`)[0];
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ username: "saved-user", password: "pw" });
|
||||
});
|
||||
|
||||
it("keeps a username the user typed over the saved one", async () => {
|
||||
stubFetch({
|
||||
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
|
||||
[`GET ${base}/credentials`]: () => json({ ...noLogin, username: "saved-user" }),
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
const input = await screen.findByPlaceholderText<HTMLInputElement>("Username");
|
||||
fireEvent.change(input, { target: { value: "typed" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(input.value).toBe("typed");
|
||||
});
|
||||
|
||||
it("shows a rejected login as a form error", async () => {
|
||||
stubFetch({
|
||||
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
|
||||
[`GET ${base}/credentials`]: () => json(noLogin),
|
||||
[`PUT ${base}/credentials`]: () => json({ error: "401", code: "auth" }, 401),
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("Camera rejected that login.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows other save failures with the server's message", async () => {
|
||||
stubFetch({
|
||||
[`GET ${base}/info`]: () => json({ error: "Wrong login", code: "auth" }, 401),
|
||||
[`GET ${base}/credentials`]: () => json(noLogin),
|
||||
[`PUT ${base}/credentials`]: () => json({ error: "Camera unreachable" }, 502),
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("Camera unreachable")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens from 'Change login' and can be cancelled", async () => {
|
||||
stubFetch({
|
||||
[`GET ${base}/info`]: () => json(info),
|
||||
[`GET ${base}/snapshot`]: frame,
|
||||
[`GET ${base}/credentials`]: () => json(noLogin),
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Change login" }));
|
||||
expect(screen.getByText("Camera login")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Forget saved login" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByText("Camera login")).toBeNull();
|
||||
});
|
||||
|
||||
it("forgets a stored login and re-checks the camera", async () => {
|
||||
let stored = true;
|
||||
const fetchMock = stubFetch({
|
||||
[`GET ${base}/info`]: () =>
|
||||
stored ? json(info) : json({ error: "Wrong login", code: "auth" }, 401),
|
||||
[`GET ${base}/snapshot`]: frame,
|
||||
[`GET ${base}/credentials`]: () =>
|
||||
json(stored ? { source: "stored", username: "admin", hasPassword: true } : noLogin),
|
||||
[`DELETE ${base}/credentials`]: () => {
|
||||
stored = false;
|
||||
return json(noLogin);
|
||||
},
|
||||
});
|
||||
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Change login" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Forget saved login" }));
|
||||
|
||||
expect(await screen.findByText("Wrong login")).toBeTruthy();
|
||||
expect(calls(fetchMock, `DELETE ${base}/credentials`)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("two cameras", () => {
|
||||
it("saving one camera's login refetches only that camera", async () => {
|
||||
const other = { ...cam, id: "99999999-2222-3333-4444-555555555555", name: "Yard" };
|
||||
const otherBase = `/api/cameras/${other.id}`;
|
||||
const fetchMock = stubFetch({
|
||||
[`GET ${base}/info`]: () => json(info),
|
||||
[`GET ${base}/snapshot`]: frame,
|
||||
[`GET ${base}/credentials`]: () => json(noLogin),
|
||||
[`PUT ${base}/credentials`]: () =>
|
||||
json({ source: "stored", username: "camera", hasPassword: true }),
|
||||
[`GET ${otherBase}/info`]: () => json(info),
|
||||
[`GET ${otherBase}/snapshot`]: frame,
|
||||
});
|
||||
renderWithQuery(
|
||||
<>
|
||||
<CameraCard cam={cam} intervalMs={60_000} />
|
||||
<CameraCard cam={other} intervalMs={60_000} />
|
||||
</>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getAllByText("Hikvision DS-2CD · fw V5.7")).toHaveLength(2));
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Change login" })[0]);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => expect(calls(fetchMock, `GET ${base}/info`)).toHaveLength(2));
|
||||
expect(calls(fetchMock, `GET ${otherBase}/info`)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -1,24 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CameraInfo } from "@/lib/camera";
|
||||
import type { CameraSummary } from "@/lib/camera-registry";
|
||||
|
||||
type Problem = { kind: "inactive" | "auth" | "error"; message: string };
|
||||
import {
|
||||
useCameraInfo,
|
||||
useCameraSnapshot,
|
||||
useClearCredentials,
|
||||
useCredentialStatus,
|
||||
useResetCamera,
|
||||
useSaveCredentials,
|
||||
type CameraProblem,
|
||||
} from "./camera-queries";
|
||||
|
||||
const DEFAULT_USERNAME = "camera";
|
||||
|
||||
async function problemFrom(res: Response): Promise<Problem> {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const kind =
|
||||
data.code === "inactive" ? "inactive" : res.status === 401 || data.code === "auth" ? "auth" : "error";
|
||||
return { kind, message: data.error ?? `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
function apiUrl(cam: CameraSummary, path: string) {
|
||||
return `/api/cameras/${cam.id}/${path}`;
|
||||
}
|
||||
|
||||
/** The camera's own web interface; it can't be framed (X-Frame-Options: SAMEORIGIN). */
|
||||
function webUiUrl(cam: CameraSummary) {
|
||||
return `http://${cam.host}${cam.port === 80 ? "" : `:${cam.port}`}/`;
|
||||
@ -64,72 +59,26 @@ function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: (
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the snapshot route, requesting the next frame only after the current one has
|
||||
* arrived. Stops on the first failure and reports it; the parent restarts it by
|
||||
* clearing `paused`.
|
||||
*/
|
||||
function Snapshot({
|
||||
cam,
|
||||
intervalMs,
|
||||
paused,
|
||||
onProblem,
|
||||
}: {
|
||||
cam: CameraSummary;
|
||||
intervalMs: number;
|
||||
paused: boolean;
|
||||
onProblem: (p: Problem) => void;
|
||||
}) {
|
||||
const [frame, setFrame] = useState<string | null>(null);
|
||||
const frameRef = useRef<string | null>(null);
|
||||
const onProblemRef = useRef(onProblem);
|
||||
/** Shows the latest frame; polling itself lives in useCameraSnapshot. */
|
||||
function Snapshot({ cam, frame, paused }: { cam: CameraSummary; frame?: Blob; paused: boolean }) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Each frame gets one object URL, created and revoked in the same effect so it is freed
|
||||
// exactly once, including under Strict Mode's double rendering.
|
||||
useEffect(() => {
|
||||
onProblemRef.current = onProblem;
|
||||
});
|
||||
|
||||
useEffect(() => () => {
|
||||
if (frameRef.current) URL.revokeObjectURL(frameRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(apiUrl(cam, "snapshot"), { cache: "no-store" });
|
||||
if (cancelled) return;
|
||||
if (!res.ok) return onProblemRef.current(await problemFrom(res));
|
||||
const url = URL.createObjectURL(await res.blob());
|
||||
if (cancelled) return URL.revokeObjectURL(url);
|
||||
if (frameRef.current) URL.revokeObjectURL(frameRef.current);
|
||||
frameRef.current = url;
|
||||
setFrame(url);
|
||||
timer = setTimeout(poll, intervalMs);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
onProblemRef.current({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [cam, intervalMs, paused]);
|
||||
const img = imgRef.current;
|
||||
if (!frame || !img) return;
|
||||
const url = URL.createObjectURL(frame);
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [frame]);
|
||||
|
||||
return (
|
||||
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
|
||||
{frame && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
|
||||
<img
|
||||
src={frame}
|
||||
ref={imgRef}
|
||||
alt={cam.name ?? cam.host}
|
||||
className={`h-full w-full object-contain ${paused ? "opacity-40" : ""}`}
|
||||
/>
|
||||
@ -151,51 +100,34 @@ function CredentialsForm({
|
||||
}: {
|
||||
cam: CameraSummary;
|
||||
reason?: string;
|
||||
/** Called after a save or clear; the camera's queries have already been reset. */
|
||||
onSaved: () => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const status = useCredentialStatus(cam.id);
|
||||
const save = useSaveCredentials(cam.id);
|
||||
const clear = useClearCredentials(cam.id);
|
||||
// null until the user types, so the saved username (never the password) can prefill it.
|
||||
const [typedUsername, setTypedUsername] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const username = typedUsername ?? status.data?.username ?? DEFAULT_USERNAME;
|
||||
|
||||
// Prefill the username only; the API never returns passwords.
|
||||
useEffect(() => {
|
||||
fetch(apiUrl(cam, "credentials"))
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
// A username the user has already typed wins over the saved one.
|
||||
if (data?.username) {
|
||||
setUsername((u) => (u === DEFAULT_USERNAME ? data.username : u));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [cam]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(apiUrl(cam, "credentials"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const problem = await problemFrom(res);
|
||||
setError(problem.kind === "auth" ? "Camera rejected that login." : problem.message);
|
||||
return;
|
||||
}
|
||||
setPassword("");
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
save.mutate(
|
||||
{ username, password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setPassword("");
|
||||
onSaved();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const error = save.error ?? clear.error;
|
||||
const busy = save.isPending || clear.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
|
||||
<div className="font-medium">Camera login</div>
|
||||
@ -206,7 +138,7 @@ function CredentialsForm({
|
||||
</div>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={(e) => setTypedUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
autoComplete="off"
|
||||
required
|
||||
@ -220,15 +152,29 @@ function CredentialsForm({
|
||||
autoComplete="new-password"
|
||||
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
||||
/>
|
||||
{error && <div className="text-red-600">{error}</div>}
|
||||
{error && (
|
||||
<div className="text-red-600">
|
||||
{error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
disabled={busy}
|
||||
className="rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{saving ? "Checking…" : "Save"}
|
||||
{save.isPending ? "Checking…" : "Save"}
|
||||
</button>
|
||||
{status.data?.source === "stored" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => clear.mutate(undefined, { onSuccess: onSaved })}
|
||||
className="rounded px-3 py-1 underline disabled:opacity-50"
|
||||
>
|
||||
Forget saved login
|
||||
</button>
|
||||
)}
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel} className="rounded px-3 py-1 underline">
|
||||
Cancel
|
||||
@ -246,32 +192,15 @@ export default function CameraCard({
|
||||
cam: CameraSummary;
|
||||
intervalMs: number;
|
||||
}) {
|
||||
const [info, setInfo] = useState<CameraInfo | null>(null);
|
||||
const [problem, setProblem] = useState<Problem | null>(null);
|
||||
const info = useCameraInfo(cam.id);
|
||||
const snapshot = useCameraSnapshot(cam.id, intervalMs, info.isSuccess);
|
||||
const resetCamera = useResetCamera(cam.id);
|
||||
const [editingLogin, setEditingLogin] = useState(false);
|
||||
// Bumped to retry after a failure or a credentials change.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(apiUrl(cam, "info"))
|
||||
.then(async (res) => {
|
||||
if (cancelled) return;
|
||||
if (!res.ok) return setProblem(await problemFrom(res));
|
||||
setInfo(await res.json());
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setProblem({ kind: "error", message: String(err) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cam, attempt]);
|
||||
const problem: CameraProblem | null = info.error ?? snapshot.error;
|
||||
|
||||
function retry() {
|
||||
setProblem(null);
|
||||
setEditingLogin(false);
|
||||
setAttempt((a) => a + 1);
|
||||
resetCamera();
|
||||
}
|
||||
|
||||
const showSetup = problem?.kind === "inactive";
|
||||
@ -279,16 +208,10 @@ export default function CameraCard({
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
||||
<Snapshot
|
||||
key={attempt}
|
||||
cam={cam}
|
||||
intervalMs={intervalMs}
|
||||
paused={problem !== null || !info}
|
||||
onProblem={setProblem}
|
||||
/>
|
||||
<Snapshot cam={cam} frame={snapshot.data} paused={problem !== null || !info.isSuccess} />
|
||||
|
||||
<div>
|
||||
<div className="font-medium">{cam.name ?? info?.model ?? "Unnamed camera"}</div>
|
||||
<div className="font-medium">{cam.name ?? info.data?.model ?? "Unnamed camera"}</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-sm">
|
||||
{cam.host}:{cam.port}
|
||||
@ -302,9 +225,9 @@ export default function CameraCard({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{info?.manufacturer && (
|
||||
{info.data?.manufacturer && (
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{info.manufacturer} {info.model} · fw {info.firmwareVersion}
|
||||
{info.data.manufacturer} {info.data.model} · fw {info.data.firmwareVersion}
|
||||
</div>
|
||||
)}
|
||||
{cam.location && (
|
||||
@ -318,7 +241,7 @@ export default function CameraCard({
|
||||
<CredentialsForm
|
||||
cam={cam}
|
||||
reason={problem?.kind === "auth" ? problem.message : undefined}
|
||||
onSaved={retry}
|
||||
onSaved={() => setEditingLogin(false)}
|
||||
onCancel={problem?.kind === "auth" ? undefined : () => setEditingLogin(false)}
|
||||
/>
|
||||
)}
|
||||
@ -332,7 +255,7 @@ export default function CameraCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && info.profiles.length > 0 && (
|
||||
{info.data && info.data.profiles.length > 0 && (
|
||||
<table className="text-xs">
|
||||
<thead className="text-left text-zinc-500">
|
||||
<tr>
|
||||
@ -343,7 +266,7 @@ export default function CameraCard({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono">
|
||||
{info.profiles.map((p) => (
|
||||
{info.data.profiles.map((p) => (
|
||||
<tr key={p.token}>
|
||||
<td>{p.name ?? p.token}</td>
|
||||
<td>{p.encoding ?? "?"}</td>
|
||||
|
||||
151
src/app/camera-queries.ts
Normal file
151
src/app/camera-queries.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CameraInfo } from "@/lib/camera";
|
||||
import type { DiscoveredCamera } from "@/lib/onvif";
|
||||
|
||||
/**
|
||||
* Client-side data layer for the dashboard: React Query hooks over our own route
|
||||
* handlers. No device logic lives here; the server decides what each response means.
|
||||
*/
|
||||
|
||||
/** What the UI acts on: "inactive" shows setup steps, "auth" asks for a login. */
|
||||
export type ProblemKind = "inactive" | "auth" | "error";
|
||||
|
||||
export class CameraProblem extends Error {
|
||||
name = "CameraProblem";
|
||||
constructor(
|
||||
readonly kind: ProblemKind,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CredentialStatus {
|
||||
source: "stored" | "env" | "none";
|
||||
username: string | null;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export interface Login {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** Every query for one camera shares this prefix, so a change can reset them together. */
|
||||
export const cameraKeys = {
|
||||
all: (id: string) => ["camera", id] as const,
|
||||
info: (id: string) => ["camera", id, "info"] as const,
|
||||
snapshot: (id: string) => ["camera", id, "snapshot"] as const,
|
||||
credentials: (id: string) => ["camera", id, "credentials"] as const,
|
||||
};
|
||||
|
||||
const cameraUrl = (id: string, path: string) => `/api/cameras/${id}/${path}`;
|
||||
|
||||
async function problemFrom(res: Response): Promise<CameraProblem> {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const kind: ProblemKind =
|
||||
data.code === "inactive"
|
||||
? "inactive"
|
||||
: res.status === 401 || data.code === "auth"
|
||||
? "auth"
|
||||
: "error";
|
||||
return new CameraProblem(kind, data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
async function request(url: string, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(url, { cache: "no-store", ...init }).catch((err: unknown) => {
|
||||
throw new CameraProblem("error", err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
if (!res.ok) throw await problemFrom(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
return (await request(url, init)).json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Only plain failures are worth one retry; a login or setup problem needs the user. */
|
||||
const retryPlainFailureOnce = (failures: number, err: Error) =>
|
||||
failures < 1 && !(err instanceof CameraProblem && err.kind !== "error");
|
||||
|
||||
export function useCameraInfo(id: string) {
|
||||
return useQuery<CameraInfo, CameraProblem>({
|
||||
queryKey: cameraKeys.info(id),
|
||||
queryFn: () => requestJson(cameraUrl(id, "info")),
|
||||
retry: retryPlainFailureOnce,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the camera's snapshot every `intervalMs` while `enabled`. A slow frame is never
|
||||
* requested twice at once, polling pauses in hidden tabs, and it stops on the first
|
||||
* failure so an offline camera isn't hammered; reset the camera's queries to restart.
|
||||
*/
|
||||
export function useCameraSnapshot(id: string, intervalMs: number, enabled: boolean) {
|
||||
return useQuery<Blob, CameraProblem>({
|
||||
queryKey: cameraKeys.snapshot(id),
|
||||
queryFn: async () => (await request(cameraUrl(id, "snapshot"))).blob(),
|
||||
enabled,
|
||||
retry: false,
|
||||
refetchInterval: (query) => (query.state.status === "error" ? false : intervalMs),
|
||||
// Frames are only useful while shown; drop them as soon as the card unmounts.
|
||||
gcTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCredentialStatus(id: string) {
|
||||
return useQuery<CredentialStatus, CameraProblem>({
|
||||
queryKey: cameraKeys.credentials(id),
|
||||
queryFn: () => requestJson(cameraUrl(id, "credentials")),
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears every query for the camera and refetches the ones on screen. */
|
||||
export function useResetCamera(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.resetQueries({ queryKey: cameraKeys.all(id) });
|
||||
}
|
||||
|
||||
/** Saves a login the camera has accepted; the server tests it before storing. */
|
||||
export function useSaveCredentials(id: string) {
|
||||
const reset = useResetCamera(id);
|
||||
return useMutation<CredentialStatus, CameraProblem, Login>({
|
||||
mutationFn: (login) =>
|
||||
requestJson(cameraUrl(id, "credentials"), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(login),
|
||||
}),
|
||||
onSuccess: reset,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearCredentials(id: string) {
|
||||
const reset = useResetCamera(id);
|
||||
return useMutation<CredentialStatus, CameraProblem, void>({
|
||||
mutationFn: () => requestJson(cameraUrl(id, "credentials"), { method: "DELETE" }),
|
||||
onSuccess: reset,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ScanOptions {
|
||||
timeoutSecs: number;
|
||||
unicastSweep: boolean;
|
||||
}
|
||||
|
||||
export function useScanNetwork() {
|
||||
return useMutation<{ cameras: DiscoveredCamera[] }, CameraProblem, ScanOptions>({
|
||||
mutationFn: ({ timeoutSecs, unicastSweep }) =>
|
||||
requestJson("/api/discover", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// The server rejects timeouts outside 1–30 s; keep a half-typed value in range.
|
||||
// An emptied field reads as 0 and means "use the default".
|
||||
body: JSON.stringify({
|
||||
timeout: (timeoutSecs > 0 ? Math.min(Math.max(Math.round(timeoutSecs), 1), 30) : 5) * 1000,
|
||||
unicastSweep,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
79
src/app/camera-scanner.test.tsx
Normal file
79
src/app/camera-scanner.test.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { calls, json, renderWithQuery, stubFetch } from "../../test/dom";
|
||||
import CameraScanner from "./camera-scanner";
|
||||
|
||||
const refresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
|
||||
|
||||
const body = (fetchMock: ReturnType<typeof stubFetch>) =>
|
||||
JSON.parse(String(calls(fetchMock, "POST /api/discover")[0][1]?.body));
|
||||
|
||||
describe("CameraScanner", () => {
|
||||
beforeEach(() => refresh.mockReset());
|
||||
|
||||
it("scans, reports the count and refreshes the server-rendered list", async () => {
|
||||
const fetchMock = stubFetch({
|
||||
"POST /api/discover": () => json({ cameras: [{ id: "a" }, { id: "b" }] }),
|
||||
});
|
||||
renderWithQuery(<CameraScanner />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
||||
expect(await screen.findByText("Scan found 2 cameras")).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" }] }) });
|
||||
renderWithQuery(<CameraScanner />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
||||
expect(await screen.findByText("Scan found 1 camera")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the button while scanning", 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 button = await screen.findByRole<HTMLButtonElement>("button", { name: "Scanning…" });
|
||||
expect(button.disabled).toBe(true);
|
||||
finish(json({ cameras: [] }));
|
||||
await screen.findByRole("button", { name: "Scan network" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an in-range value", "12", 12_000],
|
||||
["a value above 30 s", "90", 30_000],
|
||||
["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: [] }) });
|
||||
renderWithQuery(<CameraScanner />);
|
||||
|
||||
fireEvent.change(screen.getByRole("spinbutton"), { target: { value: typed } });
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
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 });
|
||||
});
|
||||
|
||||
it("shows a failed scan and does not refresh", async () => {
|
||||
stubFetch({ "POST /api/discover": () => json({ error: "Network scan failed" }, 500) });
|
||||
renderWithQuery(<CameraScanner />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
||||
expect(await screen.findByText("Error: Network scan failed")).toBeTruthy();
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when the error body isn't JSON", async () => {
|
||||
stubFetch({ "POST /api/discover": () => new Response("oops", { status: 503 }) });
|
||||
renderWithQuery(<CameraScanner />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Scan network" }));
|
||||
expect(await screen.findByText("Error: HTTP 503")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useScanNetwork } from "./camera-queries";
|
||||
|
||||
/**
|
||||
* Runs a network scan. The scan writes the server-side registry, so afterwards the
|
||||
@ -9,46 +10,22 @@ import { useState } from "react";
|
||||
*/
|
||||
export default function CameraScanner() {
|
||||
const router = useRouter();
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeout, setTimeoutSecs] = useState(5);
|
||||
const scan = useScanNetwork();
|
||||
const [timeoutSecs, setTimeoutSecs] = useState(5);
|
||||
const [unicastSweep, setUnicastSweep] = useState(true);
|
||||
|
||||
async function scan() {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/discover", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// The server rejects timeouts outside 1–30 s; keep a half-typed value in range.
|
||||
body: JSON.stringify({
|
||||
timeout: Math.min(Math.max(Math.round(timeout) || 5, 1), 30) * 1000,
|
||||
unicastSweep,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
const found = data.cameras.length;
|
||||
setResult(`Scan found ${found} camera${found === 1 ? "" : "s"}`);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
const found = scan.data?.cameras.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={scan}
|
||||
disabled={scanning}
|
||||
onClick={() =>
|
||||
scan.mutate({ timeoutSecs, unicastSweep }, { onSuccess: () => router.refresh() })
|
||||
}
|
||||
disabled={scan.isPending}
|
||||
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{scanning ? "Scanning…" : "Scan network"}
|
||||
{scan.isPending ? "Scanning…" : "Scan network"}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Timeout
|
||||
@ -56,7 +33,7 @@ export default function CameraScanner() {
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={timeout}
|
||||
value={timeoutSecs}
|
||||
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
|
||||
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
|
||||
/>
|
||||
@ -70,8 +47,12 @@ export default function CameraScanner() {
|
||||
/>
|
||||
Unicast sweep of local subnet
|
||||
</label>
|
||||
{result && <p className="w-full text-sm text-zinc-600 dark:text-zinc-400">{result}</p>}
|
||||
{error && <p className="w-full text-sm text-red-600">Error: {error}</p>}
|
||||
{found !== undefined && (
|
||||
<p className="w-full text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Scan found {found} camera{found === 1 ? "" : "s"}
|
||||
</p>
|
||||
)}
|
||||
{scan.isError && <p className="w-full text-sm text-red-600">Error: {scan.error.message}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
27
src/app/refresh-rate-select.test.tsx
Normal file
27
src/app/refresh-rate-select.test.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import RefreshRateSelect from "./refresh-rate-select";
|
||||
|
||||
const replace = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace }),
|
||||
usePathname: () => "/",
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RefreshRateSelect", () => {
|
||||
it("shows the current rate and every option in seconds", () => {
|
||||
render(<RefreshRateSelect value={500} />);
|
||||
const select = screen.getByRole<HTMLSelectElement>("combobox");
|
||||
expect(select.value).toBe("500");
|
||||
expect([...select.options].map((o) => o.text)).toEqual(["0.25 s", "0.5 s", "1 s", "2 s", "5 s"]);
|
||||
});
|
||||
|
||||
it("writes the chosen rate to the URL without scrolling", () => {
|
||||
render(<RefreshRateSelect value={1000} />);
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "250" } });
|
||||
expect(replace).toHaveBeenCalledWith("/?refresh=250", { scroll: false });
|
||||
});
|
||||
});
|
||||
49
test/dom.tsx
Normal file
49
test/dom.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/** Same defaults as the app's Providers, but retries fire immediately. */
|
||||
export function testQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 0, retry: 1, retryDelay: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function renderWithQuery(ui: ReactNode, client = testQueryClient()) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { client, ...render(ui, { wrapper }) };
|
||||
}
|
||||
|
||||
type Handler = (init: RequestInit | undefined) => Response | Promise<Response>;
|
||||
|
||||
/**
|
||||
* Stubs global fetch with handlers keyed by "METHOD /path". Unmatched requests fail the
|
||||
* test loudly instead of hitting the network.
|
||||
*/
|
||||
export function stubFetch(routes: Record<string, Handler>) {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input), "http://localhost");
|
||||
const key = `${init?.method ?? "GET"} ${url.pathname}`;
|
||||
const handler = routes[key];
|
||||
if (!handler) throw new Error(`Unexpected fetch: ${key}`);
|
||||
return handler(init);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
export const json = (body: unknown, status = 200) => Response.json(body, { status });
|
||||
|
||||
export const calls = (fetchMock: ReturnType<typeof stubFetch>, key: string) =>
|
||||
fetchMock.mock.calls.filter(
|
||||
([input, init]) =>
|
||||
`${init?.method ?? "GET"} ${new URL(String(input), "http://localhost").pathname}` === key,
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user