From 870b4cd2af84047d81acf70cf32d1fb0123ed087 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 09:01:34 -0500 Subject: [PATCH] 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) --- .vrek/log.ndjson | 22 +++ src/app/camera-card.test.tsx | 265 +++++++++++++++++++++++++++ src/app/camera-card.tsx | 221 ++++++++-------------- src/app/camera-queries.ts | 151 +++++++++++++++ src/app/camera-scanner.test.tsx | 79 ++++++++ src/app/camera-scanner.tsx | 51 ++---- src/app/refresh-rate-select.test.tsx | 27 +++ test/dom.tsx | 49 +++++ 8 files changed, 681 insertions(+), 184 deletions(-) create mode 100644 src/app/camera-card.test.tsx create mode 100644 src/app/camera-queries.ts create mode 100644 src/app/camera-scanner.test.tsx create mode 100644 src/app/refresh-rate-select.test.tsx create mode 100644 test/dom.tsx diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index a839606..c3261bf 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -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"} diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx new file mode 100644 index 0000000..fc594fd --- /dev/null +++ b/src/app/camera-card.test.tsx @@ -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(); + + 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(); + 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(); + + 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(); + 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(); + + 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(); + 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(); + + 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(); + + expect(await screen.findByText("Wrong login")).toBeTruthy(); + // Prefilled with the saved username; the password never comes back. + await waitFor(() => + expect(screen.getByPlaceholderText("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(); + + const input = await screen.findByPlaceholderText("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(); + + 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(); + + 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(); + + 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(); + + 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( + <> + + + , + ); + 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); + }); +}); diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx index 5b68329..79c95d5 100644 --- a/src/app/camera-card.tsx +++ b/src/app/camera-card.tsx @@ -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 { - 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(null); - const frameRef = useRef(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(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 | 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 (
{frame && ( // eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable {cam.name @@ -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(null); const [password, setPassword] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(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 (
Camera login
@@ -206,7 +138,7 @@ function CredentialsForm({
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 &&
{error}
} + {error && ( +
+ {error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message} +
+ )}
+ {status.data?.source === "stored" && ( + + )} {onCancel && (