From 0ec434ca26cddc35e2aa1c81fa113cc9e89ebaaf Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 13:00:37 -0500 Subject: [PATCH] Add read-only camera settings page and dashboard stream summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only settings and stream URLs (vrek gol-2nxgqjn), shaped by a read-only probe of the real cameras (findings fnd-*) and decisions dec-s205dvs and dec-gtw493s. - src/lib/camera-settings.ts: reads streams, image, time/NTP and network over ONVIF behind a CameraSettingsSource interface, so an ISAPI source can be added later. Sections are read independently and fail on their own with fixed text. RTSP URLs have credentials stripped and use the registry host. camera.ts exports its cached connect() and the profile helpers it shares with it (iss-7eaywtf). - /cameras/[id]: server-rendered page with an access check, 404 for unknown ids or non-private hosts, and a streamed Suspense section. Shows streams (with copyable RTSP URLs and an ONVIF-login note), image, time/NTP with a drift warning, and network, plus Refresh and back. Copy falls back to execCommand over plain LAN HTTP, where the clipboard API is unavailable (iss-s935a4n). - Dashboard card: the name links to the camera page; a main-stream summary line ("Main 4096×1860 · H.264 · 20 fps"); a manufacturer of "ONVIF" is hidden (iss-pv2bvzj). 522 tests, 99.9% line coverage. Checked by the user in a browser against both cameras. Refreshes the vrek export (the icon principle now covers the page's new icons). Co-Authored-By: Claude Opus 5 (1M context) --- .vrek/log.ndjson | 82 ++++ src/app/camera-card.test.tsx | 35 ++ src/app/camera-card.tsx | 16 +- src/app/camera-summary.test.ts | 91 +++++ src/app/camera-summary.ts | 48 +++ src/app/cameras/[id]/client-controls.test.tsx | 125 ++++++ src/app/cameras/[id]/client-controls.tsx | 94 +++++ src/app/cameras/[id]/format.test.ts | 94 +++++ src/app/cameras/[id]/format.ts | 74 ++++ src/app/cameras/[id]/page.test.tsx | 90 ++++ src/app/cameras/[id]/page.tsx | 58 +++ .../cameras/[id]/settings-sections.test.tsx | 252 ++++++++++++ src/app/cameras/[id]/settings-sections.tsx | 268 ++++++++++++ src/lib/camera-settings.test.ts | 384 ++++++++++++++++++ src/lib/camera-settings.ts | 290 +++++++++++++ src/lib/camera.ts | 18 +- 16 files changed, 2009 insertions(+), 10 deletions(-) create mode 100644 src/app/camera-summary.test.ts create mode 100644 src/app/camera-summary.ts create mode 100644 src/app/cameras/[id]/client-controls.test.tsx create mode 100644 src/app/cameras/[id]/client-controls.tsx create mode 100644 src/app/cameras/[id]/format.test.ts create mode 100644 src/app/cameras/[id]/format.ts create mode 100644 src/app/cameras/[id]/page.test.tsx create mode 100644 src/app/cameras/[id]/page.tsx create mode 100644 src/app/cameras/[id]/settings-sections.test.tsx create mode 100644 src/app/cameras/[id]/settings-sections.tsx create mode 100644 src/lib/camera-settings.test.ts create mode 100644 src/lib/camera-settings.ts diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson index 000ddac..2bc9ae2 100644 --- a/.vrek/log.ndjson +++ b/.vrek/log.ndjson @@ -423,3 +423,85 @@ {"id":"evt-0aznmpcdcg5p","type":"edge.added","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-8ev5m89","to":"gol-146a3cb"},"at":"2026-09-19T17:35:04.938Z","parents":["evt-20am9fjb935n"],"hash":"3a3810eedfea771a59f6c16952efbf7486403f311c578f2b0aa722195231fa54"} {"id":"evt-gatzwhb87efd","type":"edge.added","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-8ev5m89","to":"gol-wqf95dq"},"at":"2026-09-19T17:35:04.939Z","parents":["evt-0aznmpcdcg5p"],"hash":"a9535734194fc658c2fb9c0094e8b8bcf6be3e3f1f0015f5c3ab985ccba7765c"} {"id":"evt-n0bphgm2fydj","type":"edge.added","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"pri-8ev5m89","to":"dec-1vnz5fw"},"at":"2026-09-19T17:35:09.439Z","parents":["evt-gatzwhb87efd"],"hash":"4594421840fb035833e09ea618381aea810035288b5410e209bd321fae616bc4"} +{"id":"evt-60ngdx4pts6d","type":"node.created","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"goal","title":"Read-only camera settings and stream URLs on the dashboard","body":"Each camera card shows its key settings and how to reach its video, without changing anything on the camera. Settings: per stream (main/sub) codec, resolution, frame rate and bitrate; image basics such as day/night mode; clock and time sync (NTP) state; model, firmware and serial. Stream URLs: the RTSP address per stream, ready to paste into VLC or an NVR, with credentials never embedded in the URL or sent to the browser. Read over ONVIF where it's enough and over Hikvision ISAPI (shared by Annke) where it isn't, behind a vendor module so the UI never branches on brand (pri-p9h51hx). Read-only by design: nothing here writes to a camera. Picks up the 'key settings' part of the retired gol-146a3cb; changing settings is gol for camera configuration.","status":"active","owner":null,"attrs":{},"weight":null,"target":null,"direction":"up","unit":null},"at":"2026-09-19T17:37:49.859Z","parents":["evt-n0bphgm2fydj"],"hash":"a817a6b82651a1af75d87e7e9dd17eefeb1631733966399fb21d2346febe2d49"} +{"id":"evt-a7bezmmb9qc0","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-2nxgqjn","to":"gol-6q1q5mr"},"at":"2026-09-19T17:37:49.860Z","parents":["evt-60ngdx4pts6d"],"hash":"c3ebe8aaca2e78a038d59deae99912a7031833d0de0739575aab071c3cc5210a"} +{"id":"evt-erpc1hn1d6ay","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-01np850"},"at":"2026-09-19T17:37:49.861Z","parents":["evt-a7bezmmb9qc0"],"hash":"44719ea94206797eeff9a188ba733c6bc1118c638a621c640a7fd13d2d87a141"} +{"id":"evt-9bh86smy574k","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-p9h51hx"},"at":"2026-09-19T17:37:49.862Z","parents":["evt-erpc1hn1d6ay"],"hash":"fb0c753fc9654a394ef7dac64ff439d80c1ddfc0586eea7612dda1e264e6c641"} +{"id":"evt-1rzt5s87wpb9","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-tyrxdz9"},"at":"2026-09-19T17:37:49.863Z","parents":["evt-9bh86smy574k"],"hash":"fa13d2d915a77581eede322859e714109c34a7fbdef1671a353883ead693be94"} +{"id":"evt-y34bdvfbdb8k","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-e14bahk"},"at":"2026-09-19T17:37:49.864Z","parents":["evt-1rzt5s87wpb9"],"hash":"c5a51b9fae66d91a3809921b705ce30fbbef9ecc5b8821e4abfda9f2bd862b59"} +{"id":"evt-eg049qzbbwgb","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-be2smzk"},"at":"2026-09-19T17:37:49.865Z","parents":["evt-y34bdvfbdb8k"],"hash":"05ec19d38ecef1fc9484de0d0a7eacdc4c3387e87b282de01a0028419347e9d1"} +{"id":"evt-9by655qt0xf7","type":"node.created","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"goal","title":"Configure cameras from the dashboard","body":"Change camera settings from the same screen instead of each camera's own web page: e.g. stream encoding (resolution, frame rate, bitrate), image and day/night settings, clock and NTP, and actions such as reboot; pan/tilt/zoom where supported. Writes go through Server Actions or validated route handlers, over ONVIF or Hikvision ISAPI (Annke shares it) behind the vendor module. Every change is validated with zod, confirmed for disruptive actions (reboot, anything that drops the stream), re-read from the camera afterwards to show what actually applied, and reported as a clear success or error. Requires an admin session (gol-wqf95dq). Picks up the 'changes made from the same screen' part of the retired gol-146a3cb, and builds on the read-only settings goal.","status":"active","owner":null,"attrs":{},"weight":null,"target":null,"direction":"up","unit":null},"at":"2026-09-19T17:37:54.299Z","parents":["evt-eg049qzbbwgb"],"hash":"5381314045a8c4f5449d14c15de474bac263102458791da199daa16bf9d2b8e2"} +{"id":"evt-hgjd5n8q793c","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-8v1wqvp","to":"gol-6q1q5mr"},"at":"2026-09-19T17:37:54.300Z","parents":["evt-9by655qt0xf7"],"hash":"a19dea7cc9e625db9195110b3cb1dfeb903b31f50aa79ab6be1d5d73876dc338"} +{"id":"evt-af4q8j0hgsye","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-01np850"},"at":"2026-09-19T17:37:54.301Z","parents":["evt-hgjd5n8q793c"],"hash":"76c5c362ea623fb8465308f30c0803afcc01be04b3529f7b720ced31e3f3ed3a"} +{"id":"evt-vrkawxerr38x","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-p9h51hx"},"at":"2026-09-19T17:37:54.302Z","parents":["evt-af4q8j0hgsye"],"hash":"4d7f8beaba527b3f6a68afe4564cd49cc53debe1302169f2bd14ee6b734a45a2"} +{"id":"evt-6j5vdg23ttb6","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-tyrxdz9"},"at":"2026-09-19T17:37:54.303Z","parents":["evt-vrkawxerr38x"],"hash":"7b739238e317346073baa36d3e28ca80b46c09e8bd7bee54ec300fef8af26537"} +{"id":"evt-5vm4bzt7kyew","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-m1csgrm"},"at":"2026-09-19T17:37:54.304Z","parents":["evt-6j5vdg23ttb6"],"hash":"96ec81d9b47ce74a43efbaa432de702b3d11d4191aa9d022cb335c872d88a1a7"} +{"id":"evt-b6n8hpkdqjsy","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-qqrp49f"},"at":"2026-09-19T17:37:54.305Z","parents":["evt-5vm4bzt7kyew"],"hash":"0c66482887917c79db820bfbb904004831e192d22e6f78feddf71a29866f7554"} +{"id":"evt-qd98rwj32rde","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-e14bahk"},"at":"2026-09-19T17:37:54.306Z","parents":["evt-b6n8hpkdqjsy"],"hash":"c748376638a82e71e4f7ed8bfe6700f55a9cd4a497fde714555ceea89d722eb6"} +{"id":"evt-gjs051d8727f","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-be2smzk"},"at":"2026-09-19T17:37:54.307Z","parents":["evt-qd98rwj32rde"],"hash":"738203f17d37395ced7d66cfa843fa68117aa9fce1e389d5ec6b7e0a1b1a0734"} +{"id":"evt-nykjsnmwvxzj","type":"node.updated","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Each camera card shows its key settings and how to reach its video, without changing anything on the camera. Settings: per stream (main/sub) codec, resolution, frame rate and bitrate; image basics such as day/night mode; clock and time sync (NTP) state; model, firmware and serial. Stream URLs: the RTSP address per stream, ready to paste into VLC or an NVR, with credentials never embedded in the URL or sent to the browser. Read over ONVIF where it's enough and over Hikvision ISAPI (shared by Annke) where it isn't, behind a vendor module so the UI never branches on brand (pri-p9h51hx). Read-only by design: nothing here writes to a camera. Picks up the 'key settings' part of the retired gol-146a3cb; changing settings belongs to gol-8v1wqvp."}},"at":"2026-09-19T17:38:01.500Z","parents":["evt-gjs051d8727f"],"hash":"c7163c8951f71cd4873ca19cf0461d4713f4c62c23885d7827525284b8dc0af6"} +{"id":"evt-c7h5r80v9ceq","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"depends_on","from":"gol-8v1wqvp","to":"gol-2nxgqjn"},"at":"2026-09-19T17:38:02.692Z","parents":["evt-nykjsnmwvxzj"],"hash":"bb333c60431c0d49158a6e565ae959c617244aff8a13c4c9cf910a1ed981ae8c"} +{"id":"evt-ss7yazceygd9","type":"node.status_changed","subject":"gol-146a3cb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"active","to":"retired"},"at":"2026-09-19T17:38:04.010Z","parents":["evt-c7h5r80v9ceq"],"hash":"2e31be3b571523013547401bed8137756fe71f0293a40de3406a8cacc3ace43e"} +{"id":"evt-3ezkdkzyhrk2","type":"node.updated","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"weight":1}},"at":"2026-09-19T17:38:38.157Z","parents":["evt-ss7yazceygd9"],"hash":"6865d5928a06a2c9577ba47023f0bb7531f48fae0ff85ec380eadd408a92f2d6"} +{"id":"evt-cqg4b4159vxk","type":"node.updated","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"weight":0.9}},"at":"2026-09-19T17:38:39.226Z","parents":["evt-3ezkdkzyhrk2"],"hash":"a5554cd400a167ac0ae20a142663a1c50b3216cde1b2c936f2852c0a31a78456"} +{"id":"evt-ejj3adqg8gn1","type":"node.created","subject":"dec-s205dvs","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Read-only settings: ONVIF first (ISAPI later), credential-free stream URLs, per-camera page","body":"User's choices, 2026-09-19, for gol-2nxgqjn. (1) Source: ONVIF only for now, using the stored Operator ONVIF login. The camera-reading code goes behind a vendor module so Hikvision ISAPI can be added later as its own goal, once we know what's missing. Rejected for now: ONVIF + ISAPI, which likely needs a second, device-level login stored per camera. (2) Stream URLs are shown without credentials (e.g. rtsp://host:554/Streaming/Channels/101), with a copy button and a note on which camera account to enter in VLC or an NVR; passwords never reach the browser (pri-tyrxdz9). Rejected: a USERNAME:PASSWORD placeholder URL. (3) Layout: a per-camera page /cameras/[id] holds all settings, and the dashboard stays an overview; this page is also the natural home for configuration (gol-8v1wqvp). Rejected: an expandable details panel on the card, and showing everything on the card. (4) The user permitted a one-off read-only probe of the real cameras (ONVIF reads, RTSP DESCRIBE, ISAPI GET; nothing written) to see what they actually report. Automated tests still use fakes (pri-e14bahk).","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:41:35.925Z","parents":["evt-cqg4b4159vxk"],"hash":"b769a2494005167f9bf489455c4c1d84b03a33b78f79e8c3e00ad7b4b79f42b0"} +{"id":"evt-br7qz2w9t36s","type":"edge.added","subject":"dec-s205dvs","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-s205dvs","to":"gol-2nxgqjn"},"at":"2026-09-19T17:41:35.926Z","parents":["evt-ejj3adqg8gn1"],"hash":"d01a6d86536dc481dffdd4ac9cee5646916fef4c646c42f8c57d4668a13ccbc2"} +{"id":"evt-k85dbe7d3ftj","type":"node.created","subject":"fnd-j1s725e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Both cameras report full per-stream encoding settings and RTSP URIs over ONVIF with the stored Operator login","body":"Two I91ET cameras, firmware V5.8.10, at 192.168.17.129 and 192.168.31.2. Profile_1 'mainStream': H.264 Main, 4096×1860, 20 fps limit, variable bitrate (ConstantBitRate=false), bitrateLimit 10240 kbps, GOP 50, quality 3. Profile_2 'subStream': H.264 Main, 1200×536, 20 fps, VBR 4096 kbps, GOP 50, quality 3. GetStreamUri (RTSP) returns rtsp:///Streaming/Channels/101 (main) and /102 (sub), with ?transportmode=unicast&profile=Profile_N; no credentials are embedded and the port is the default 554. The app already receives bitrate, GOP and quality in the profile data but doesn't show them.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"one-off read-only probe run by Claude on 2026-09-19 with the user's permission; ONVIF GetProfiles/GetStreamUri"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:40.302Z","parents":["evt-br7qz2w9t36s"],"hash":"db4d5a9b9545811b76df90a5c169128dcdd806e3bbcf0777e8d0979025ae4589"} +{"id":"evt-mr233cpb084k","type":"edge.added","subject":"fnd-j1s725e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-j1s725e","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:40.303Z","parents":["evt-k85dbe7d3ftj"],"hash":"226479c8604b5b4265e93e8ef862167a82876a3e6c7b04f6ad1e2bf58da19d70"} +{"id":"evt-ccxmzx4prcmt","type":"edge.added","subject":"fnd-j1s725e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-j1s725e","to":"gol-8v1wqvp"},"at":"2026-09-19T17:42:40.304Z","parents":["evt-mr233cpb084k"],"hash":"9594c0b1e1d8d431e3c6b1059e5d87310854c6db3910fdd6fc5ebb8fe97daf6c"} +{"id":"evt-9wfmkcc39hqm","type":"node.created","subject":"fnd-g4z7c6d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"ONVIF imaging settings are readable: day/night (IR-cut), exposure, white balance, WDR, BLC, picture levels, noise reduction","body":"GetImagingSettings(VideoSource_1) on both cameras: irCutFilter AUTO (day/night switching automatic); brightness, contrast, colorSaturation and sharpness all 50; exposure AUTO (10–33333 µs, gain 0–100); whiteBalance AUTO; wideDynamicRange OFF; backlightCompensation OFF; noiseReduction level 0.5 (nested under extension.extension.extension). So the goal's 'day/night mode' and image basics need no ISAPI.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ONVIF GetImagingSettings"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:43.116Z","parents":["evt-ccxmzx4prcmt"],"hash":"bb5f691a87f061edd875104735aa44f4b44add6d7173519d8fabe17c8e99e24f"} +{"id":"evt-9yvajdtwc985","type":"edge.added","subject":"fnd-g4z7c6d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-g4z7c6d","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:43.117Z","parents":["evt-9wfmkcc39hqm"],"hash":"fce71707585af783791666255828b115a2446973dd15addb64e7e66d0272a409"} +{"id":"evt-vbfksk3rgsev","type":"edge.added","subject":"fnd-g4z7c6d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-g4z7c6d","to":"gol-8v1wqvp"},"at":"2026-09-19T17:42:43.118Z","parents":["evt-9yvajdtwc985"],"hash":"7e398c7e19cf86ddf4aff7f18d47b694a266ddd46b7d654325134e886ad51801"} +{"id":"evt-yfwzyk1kn6cf","type":"node.created","subject":"fnd-b73cav4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Clock, NTP and network settings are readable over ONVIF","body":"Clock drift versus the server is within 1 s on both. NTP is manual, via DNS name time.windows.com (not from DHCP). Network: eth0, IPv4 by DHCP with a /20 prefix (192.168.16.0/20, gateway 192.168.16.1), 100 Mb full duplex, MACs d0:3b:f4:04:af:2e and d0:3b:f4:04:af:54. IPv6 is enabled with a link-local and a global SLAAC address (2001:470:…). The hostname reports 'localhost', which isn't useful as a display name.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ONVIF GetSystemDateAndTime, GetNTP, GetNetworkInterfaces, GetNetworkDefaultGateway, GetHostname"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:46.597Z","parents":["evt-vbfksk3rgsev"],"hash":"01ba415c8e3edeb4c8e5c1e285597fa58a2525dc357164aae1521042035edfb5"} +{"id":"evt-xvfjkb6gfz5f","type":"edge.added","subject":"fnd-b73cav4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-b73cav4","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:46.599Z","parents":["evt-yfwzyk1kn6cf"],"hash":"d6d7c590946c8cfb4ead73c403b8ea71d19ed57a85d32388e2d49547889d204b"} +{"id":"evt-esfr9ypd97ks","type":"node.created","subject":"fnd-nj8n90g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"The RTSP stream accepts the stored ONVIF user on both cameras","body":"An RTSP DESCRIBE on Channels/101 with Digest auth as the stored ONVIF Operator user 'camera' returned 'RTSP/1.0 200 OK' on both cameras. So the credential-free stream URLs can tell users to sign in with that same ONVIF account in VLC or an NVR; no device (web) account is needed for streaming.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; RTSP DESCRIBE only, no PLAY"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:48.834Z","parents":["evt-xvfjkb6gfz5f"],"hash":"0d9bf4669e8f7879d9e6f8cfe6daa29d42970fe4db9f1a8d3e6e60fa341d9d05"} +{"id":"evt-6h02q06frnwr","type":"edge.added","subject":"fnd-nj8n90g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-nj8n90g","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:48.835Z","parents":["evt-esfr9ypd97ks"],"hash":"3be08e13456aee6a89b6141871b05c8982ab97af38ef28d228384f20f529a781"} +{"id":"evt-a0ntbmwd27pv","type":"node.created","subject":"fnd-hcnbvht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"ISAPI with the ONVIF user is inconsistent: 401 on one camera, 200 on the other","body":"GET /ISAPI/System/deviceInfo with Digest auth as 'camera': 192.168.17.129 → HTTP 401; 192.168.31.2 → HTTP 200 (model I91ET). Likely the second camera also has a device (web) account named 'camera' with the same password, or its ONVIF and device users are configured differently. Implication for the future ISAPI goal: ISAPI access can't be assumed from the ONVIF login; it needs per-camera verification or its own credentials.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ISAPI GET only"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:51.646Z","parents":["evt-6h02q06frnwr"],"hash":"65dd78de0461f84ac721fd77b40800b21cd2c00cbca3a442e62a9c16a44e2faa"} +{"id":"evt-x059s99sa5x3","type":"edge.added","subject":"fnd-hcnbvht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-hcnbvht","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:51.647Z","parents":["evt-a0ntbmwd27pv"],"hash":"bc0dadfdc5a916b998b74643392ab0e2d94619da5fb6e18cf93833153a644bd2"} +{"id":"evt-b6j6tnjvkcpj","type":"edge.added","subject":"fnd-hcnbvht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-hcnbvht","to":"gol-8v1wqvp"},"at":"2026-09-19T17:42:51.648Z","parents":["evt-x059s99sa5x3"],"hash":"00d7d68951b7026d979cef0b170d2fd07fa4a12d19cde6b654924e28db8348b5"} +{"id":"evt-xby2yehfxjfv","type":"node.created","subject":"fnd-msm9prc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"The cameras report their manufacturer as 'ONVIF', not a brand","body":"GetDeviceInformation returns manufacturer 'ONVIF', model 'I91ET', firmware 'V5.8.10', serials I91ET20251117AAWRGK8004273 and …8004311, hardwareId 88. The card currently shows 'ONVIF I91ET · fw V5.8.10'; 'ONVIF' as a manufacturer is noise to users. Brand detection (Hikvision vs Annke) can't rely on this field.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ONVIF GetDeviceInformation"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:53.435Z","parents":["evt-b6j6tnjvkcpj"],"hash":"287226826bdedb2338f1adcba16d0f35b594520b9a98a3b7017a9cd138fccce0"} +{"id":"evt-tfxg7dxw16f6","type":"edge.added","subject":"fnd-msm9prc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-msm9prc","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:53.437Z","parents":["evt-xby2yehfxjfv"],"hash":"a3a1a47b8e2c4314fc168f5e71526ad13f1705394afd200e618c01d8beb6d035"} +{"id":"evt-234mkjnf06xr","type":"node.created","subject":"dec-gtw493s","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Camera page: streams+URLs, image, time/NTP, network; read on open with Refresh; name links; hide 'ONVIF' brand","body":"User's choices, 2026-09-19, for gol-2nxgqjn (following the probe findings fnd-j1s725e, fnd-g4z7c6d, fnd-b73cav4, fnd-nj8n90g, fnd-hcnbvht, fnd-msm9prc). (1) /cameras/[id] shows four sections: Streams + URLs (per profile: codec and profile, resolution, fps, bitrate with VBR/CBR, GOP, quality; RTSP URL with a copy button and a note to use the camera's ONVIF login, which RTSP accepts); Image (IR-cut day/night, exposure, white balance, WDR, BLC, picture levels, noise reduction); Time & NTP (camera clock, drift from the server with a warning above 30 s, NTP server and source); Network (IPv4 address/prefix, DHCP or static, gateway, MAC, link speed/duplex, IPv6). (2) Freshness: read once when the page opens, plus a Refresh button; no polling. Rejected: polling every 30 s. (3) Navigation: the camera name on each dashboard card links to its page, and the card gains a one-line summary (e.g. 'Main 4096×1860 · H.264 · 20 fps'). Rejected: a Details button only, or a fully clickable card. (4) A manufacturer of 'ONVIF' (or empty) counts as unknown, so the card shows 'I91ET · fw V5.8.10'; real brand names still show. Rejected: showing it as reported, or a per-camera brand field.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:44:33.625Z","parents":["evt-tfxg7dxw16f6"],"hash":"f53838266524af249d7562b7f14b969b9d51ff1525cc1811af24953d1bf59b5c"} +{"id":"evt-3hqybbe8wprr","type":"edge.added","subject":"dec-gtw493s","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-gtw493s","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:33.626Z","parents":["evt-234mkjnf06xr"],"hash":"ca6c5aaeec39f901974615a60903fae3f2486d71055be3c318c3505017ecf44d"} +{"id":"evt-875z735p1rhh","type":"node.created","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Per-camera settings page /cameras/[id] (read-only)","body":"Parent for gol-2nxgqjn, following dec-s205dvs and the camera-page decision. Done when every child is closed and the user has checked the page against both real cameras. Coverage stays at or above 95% (pri-be2smzk); tests use fakes (pri-e14bahk); icons follow pri-8ev5m89.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:35.653Z","parents":["evt-3hqybbe8wprr"],"hash":"f9374d2ba8e93f556efe5581d0e0b727ab92b0864caf4720baf071ae5aa6e8da"} +{"id":"evt-w0c4q5njj8xn","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-cngxt98","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:35.654Z","parents":["evt-875z735p1rhh"],"hash":"bff45c0b24fe36ffc7aa46d94abfa5c6a2e7fcc75fe3e827a021c010b6132b6b"} +{"id":"evt-zyb1vp2fxmng","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-cngxt98","to":"area:settings"},"at":"2026-09-19T17:44:35.655Z","parents":["evt-w0c4q5njj8xn"],"hash":"2d305815f1abed34a844dd73ee9253e7117fc168dd92b650116a69e1f2b3bad1"} +{"id":"evt-nvh5gb4t22nq","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-cngxt98","to":"area:ui"},"at":"2026-09-19T17:44:35.656Z","parents":["evt-zyb1vp2fxmng"],"hash":"a9f0fa118a168ca441ffbf1407b48eac143b548fe96d7fdef4f58c0ccd746599"} +{"id":"evt-gqgzzcb0xfxq","type":"node.created","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Camera settings reader: ONVIF implementation behind a vendor-neutral interface","body":"src/lib/camera-settings.ts (server-only). getCameraSettings(target) returns a client-safe DTO: streams[] (token, name, codec, H.264/H.265 profile, width, height, fps, bitrate kbps, constantBitRate, GOP, quality, rtspUrl), image (irCut, exposure, whiteBalance, wdr, blc, brightness, contrast, saturation, sharpness, noiseReduction), time (cameraTime, driftSeconds, ntp: source and servers), network (per interface: name, MAC, IPv4 address/prefix, dhcp, IPv6 addresses, speed/duplex; default gateway). Implemented with ONVIF calls on the cached connection from camera.ts (GetProfiles data, GetStreamUri, GetImagingSettings, GetSystemDateAndTime, GetNTP, GetNetworkInterfaces, GetNetworkDefaultGateway), shaped by a documented `CameraSettingsSource` interface so an ISAPI source can be added later (pri-p9h51hx). Each section is read independently: one failing call (e.g. imaging unsupported) yields that section as 'unavailable' without failing the page. RTSP URLs are sanitized: any userinfo is stripped, and the host is forced to the registry address. Auth and inactive errors map as in camera.ts. Tests use a scripted fake Cam with shapes taken from the probe findings (fnd-j1s725e, fnd-g4z7c6d, fnd-b73cav4), including Media2-style and missing fields.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:44.220Z","parents":["evt-nvh5gb4t22nq"],"hash":"a57e15bc20bd231f5aa1d420e4e2985fa96391fc9b3a0cb84a434c7b73ffbf2b"} +{"id":"evt-z274n0kxst0r","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-7eaywtf","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:44.221Z","parents":["evt-gqgzzcb0xfxq"],"hash":"154896f66f69f28067b7dc40effbb4481005e5a216c91359dfa9d6e78f613c80"} +{"id":"evt-gqk010w07854","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7eaywtf","to":"src/lib/camera-settings.ts"},"at":"2026-09-19T17:44:44.222Z","parents":["evt-z274n0kxst0r"],"hash":"c7a0bd30401e64c5158a7c492ee5c94af2a25edd548c264cab724625d26a5e04"} +{"id":"evt-373vmvxm7sq2","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7eaywtf","to":"src/lib/camera.ts"},"at":"2026-09-19T17:44:44.223Z","parents":["evt-gqk010w07854"],"hash":"75bb6c815ea0224375627575604f5890e42171f9fd0401e333f8ce4b4d2ecaa1"} +{"id":"evt-cxetb15ctatx","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-cngxt98","to":"iss-7eaywtf"},"at":"2026-09-19T17:44:44.224Z","parents":["evt-373vmvxm7sq2"],"hash":"069ab9298c441d45a1e1ddba8caa2db1c1e870dd42492ac5324d1db5a4ae906a"} +{"id":"evt-9qsbjnz19hyv","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-7eaywtf","to":"area:settings"},"at":"2026-09-19T17:44:44.225Z","parents":["evt-cxetb15ctatx"],"hash":"23f5f07f16477dbef80599212cfbd2ce8cb0e9ba0e09a783f1eebaa581b0f272"} +{"id":"evt-p21jybbv90p8","type":"node.created","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Build the /cameras/[id] page: four sections, Refresh, copyable RTSP URLs","body":"A Server Component page (pri-01np850) that calls requirePageAccess(), resolves the camera from the registry by id (404 for unknown or invalid ids, and only private-IP hosts per isAllowedHost), and calls getCameraSettings directly, with no fetch to our own API. Its four sections sit inside so the header renders immediately. Streams: a table per profile, plus the RTSP URL with a small client 'Copy' leaf (Copy → Check icon) and the note 'Sign in with the camera's ONVIF login (e.g. camera)'. Image: day/night shown as 'Auto (IR-cut)', etc. Time & NTP: camera clock in the browser's locale, drift with a warning at over 30 s, and the NTP server and its source. Network: IPv4, DHCP or static, gateway, MAC, link, IPv6. Unavailable sections say so without failing the page, and auth or inactive problems show the same guidance as the dashboard card. A Refresh button (a client leaf calling router.refresh()) and a back link to the dashboard. Icons per pri-8ev5m89, adding mappings for new concepts (e.g. ArrowLeft = back, Copy/Check = copy, Video = streams, Image or Sun/Moon = image, Clock = time, Network = network). Tests render with a faked settings reader.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:52.989Z","parents":["evt-9qsbjnz19hyv"],"hash":"c556d9d2a1a89d005c502dc08b3cc639ff45e011d0c522e251737ef500a8343b"} +{"id":"evt-7qyyxx2mq7sc","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-s935a4n","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:52.990Z","parents":["evt-p21jybbv90p8"],"hash":"73ad7d119a584f2c57fd54aaa685b2264b22ec454bdd4eaaf958665acdbf4aa1"} +{"id":"evt-wvb3vge5chvp","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-s935a4n","to":"src/app/cameras/[id]/"},"at":"2026-09-19T17:44:52.991Z","parents":["evt-7qyyxx2mq7sc"],"hash":"7d47fb03f9123077747a4219f47f04c53ab59d5e7c1fd014d908dbcb08b6147c"} +{"id":"evt-2nyqg79f2m78","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-7eaywtf","to":"iss-s935a4n"},"at":"2026-09-19T17:44:52.992Z","parents":["evt-wvb3vge5chvp"],"hash":"6a3c77756b61e535baaea73d6fb78f57a37e2a428b9022ca610caddfbdf52de2"} +{"id":"evt-2r58e73geybc","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-cngxt98","to":"iss-s935a4n"},"at":"2026-09-19T17:44:52.993Z","parents":["evt-2nyqg79f2m78"],"hash":"76081b066c331a0bb846d7902846dd84d9b8c20dfab774868f7bca3fac01519e"} +{"id":"evt-akxgbvb58mwv","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-s935a4n","to":"area:settings"},"at":"2026-09-19T17:44:52.994Z","parents":["evt-2r58e73geybc"],"hash":"94cab7c9e8055d67b2624bed255b0c423a86e89ca571976b97220e5d826d39d1"} +{"id":"evt-7h2qm57jgzkx","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-s935a4n","to":"area:ui"},"at":"2026-09-19T17:44:52.995Z","parents":["evt-akxgbvb58mwv"],"hash":"abb24a1d00a444152618262d308f7cb9f035fdd4c253ba89593140fcc6d31d66"} +{"id":"evt-yw0mq8fcfzzr","type":"node.created","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Dashboard card: link the name to the camera page, add a stream summary, hide 'ONVIF' as a brand","body":"On each camera card: the camera name becomes a next/link to /cameras/[id]; a one-line summary is built from the info the card already loads, e.g. 'Main 4096×1860 · H.264 · 20 fps' (main = the first or highest-resolution profile); the device line shows 'I91ET · fw V5.8.10', treating manufacturer 'ONVIF' or empty as unknown (fnd-msm9prc) while still showing real brand names. No new device calls. Tests: link href, summary text, and the brand rule for 'ONVIF', empty, and 'Hikvision'.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:56.153Z","parents":["evt-7h2qm57jgzkx"],"hash":"d45c80cf8e68882686fd6b262c5ae7efeeac92c1661afb694dc9ccaf689958ae"} +{"id":"evt-99qp337rvxfe","type":"edge.added","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-pv2bvzj","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:56.155Z","parents":["evt-yw0mq8fcfzzr"],"hash":"73ac24a1372f32c3a15acc1548f575594be1f11f872d8ce823b6122fa931f9b7"} +{"id":"evt-7r964b8eckvn","type":"edge.added","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-pv2bvzj","to":"src/app/camera-card.tsx"},"at":"2026-09-19T17:44:56.156Z","parents":["evt-99qp337rvxfe"],"hash":"0e54ab18305617b3d21c627f0fb2de19465348eb0a07f25b13764eae19b8f1a1"} +{"id":"evt-egqgafamqg6x","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-cngxt98","to":"iss-pv2bvzj"},"at":"2026-09-19T17:44:56.157Z","parents":["evt-7r964b8eckvn"],"hash":"91429b74b8ca1f3e18342bbe0a3e07cc7c6508a93d30d49230605a88a1f6b64f"} +{"id":"evt-422kqj834328","type":"edge.added","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-pv2bvzj","to":"area:ui"},"at":"2026-09-19T17:44:56.158Z","parents":["evt-egqgafamqg6x"],"hash":"2469b54f3efc7f49df6fd1c903f86520b355b71a4178f368c2dfccb49c3ea032"} +{"id":"evt-872wg16xwxe3","type":"node.created","subject":"ver-az11m1q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"getCameraSettings(target) returns a typed, client-safe DTO with four independently read sections (streams incl. credential-free RTSP URLs on the registry host, image, time/NTP with drift, network with gateways) over ONVIF, behind the CameraSettingsSource interface; one failing section is 'unavailable' with a fixed message while the others still load; connection and login errors propagate; only get* calls are made.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:48:09.526Z","parents":["evt-422kqj834328"],"hash":"66fc13edb2d27d89ae914660aab4fd2620a9b3f55931aff8e8ae37d698008dd9"} +{"id":"evt-dnd7z4yspnmd","type":"edge.added","subject":"ver-az11m1q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-az11m1q","to":"iss-7eaywtf"},"at":"2026-09-19T17:48:09.528Z","parents":["evt-872wg16xwxe3"],"hash":"24383e89462dfb9f2fe29bdda9b81dd8fa48f891f9bcfe24222d2384d60a7ca9"} +{"id":"evt-zpmx1vjh23mq","type":"verification.recorded","subject":"ver-az11m1q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/camera-settings.ts; camera.ts now exports connect(), encoderConfig() and profileToken(), with toProfile() reusing them. Tests in src/lib/camera-settings.test.ts (25) use a scripted fake camera with the exact shapes from the real-camera probe (fnd-j1s725e, fnd-g4z7c6d, fnd-b73cav4), plus Media2 profiles, stringly typed numbers and booleans, a single non-array interface, static IPv4, disabled IPv6, NTP from DHCP by IP, a bare profile, no profiles, each section failing on its own (no 'hunter2' leak), and sanitizeRtspUrl (strips credentials, forces the host, keeps port and path, accepts rtsps, rejects http and garbage); they also assert only get* methods are called. 100% lines and functions and 95% branches for the module. Full suite 441/441, coverage 99.87%, tsc, eslint and next build clean, 2026-09-19. Not run against the real cameras through this module (the probe used the same calls); the parent iss-cngxt98 requires the user's check of the page."},"at":"2026-09-19T17:48:09.529Z","parents":["evt-dnd7z4yspnmd"],"hash":"1a15b1b4ea232301c2144d17ae437ece764bf924e1c529dac90cafcf47a7b8ba"} +{"id":"evt-9mk55qmy6amx","type":"node.status_changed","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:48:10.674Z","parents":["evt-zpmx1vjh23mq"],"hash":"2cac4816dbc162eeda4bcb7fe6251ec6a5f19b7ab8fae7e821acd7723ee70f92"} +{"id":"evt-x6cbjt1gx94r","type":"node.updated","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Standing rule derived from dec-1vnz5fw (Lucide chosen 2026-09-19) and applied in iss-scj42jq, iss-chrdd0v, iss-egy082x and iss-s935a4n.\n\n1. Source: icons come from lucide-react, imported by name (`import { KeyRound } from \"lucide-react\"`); Next 16 optimizes those imports by default. No other icon library, icon font or hand-drawn SVG icon without a new dependency question (pri-mz2jxpb). Check that a name exists in the installed version before using it.\n2. Meaning: an icon never carries meaning alone. Status, warnings and actions always have visible text (e.g. the status badge's 'Needs login'), and state is never conveyed by color or icon alone.\n3. Accessibility: icons next to text are decorative and get `aria-hidden`. A control with only an icon must have an `aria-label` that says what it does. Warnings keep role=\"alert\" and their text. Adding an icon must not change a control's accessible name.\n4. Sizing and layout: size with Tailwind classes, not the `size` prop: `size-3.5` in small text and badges, `size-4` in buttons, inline messages and section headings, `size-5` in banners, `size-6` in page headings. Lay out with `inline-flex items-center gap-1…2`, and use `shrink-0` beside wrapping text.\n5. Motion and color: spinners use LoaderCircle with `animate-spin motion-reduce:animate-none`. Colored icons and badges have `dark:` variants.\n6. Consistency: one concept, one icon across the app. Current mapping: Cctv = a camera (live status; camera page heading); LoaderCircle = in progress; KeyRound = login; ShieldAlert = unsecured or not activated; ShieldCheck = secured or signed in; WifiOff = offline; CircleAlert = error message; TriangleAlert = needs attention (setup needed, clock drift); RefreshCw = retry or refresh; Radar = scan; Timer = refresh rate; Trash2 = forget; Lock = save or sign-in heading; LogIn/LogOut = session; ExternalLink = opens another site in a new tab; ArrowLeft = back to the dashboard; Video = streams; SunMoon = image and day/night settings; Clock = time and NTP; Network = network settings; Copy → Check = copy to clipboard / copied. Reuse these; if a new concept needs an icon, add it here.\n7. Tests: assert icons by their `lucide-` class with toContain, since Lucide also adds alias classes (Trash2 → 'trash' and 'trash-2'), and assert every rendered `svg.lucide` is aria-hidden (see src/app/icons.test.tsx).\n\nWhy: icons should make the dashboard faster to scan without making it less accessible or less consistent. Rules out: icon-only status, icons that change accessible names, ad-hoc SVGs, and mixing icon sets."}},"at":"2026-09-19T17:53:58.530Z","parents":["evt-9mk55qmy6amx"],"hash":"234f807d29c1359f6029e44ed4a88d4079f67f5f0d06f107cfea68151deeefe5"} +{"id":"evt-sw81v28rq819","type":"node.created","subject":"ver-ps9ex17","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"/cameras/[id] is a Server Component that checks access, 404s bad or unknown ids and non-private hosts, and renders the header immediately with the four settings sections streamed via Suspense (streams with copyable credential-free RTSP URLs and an ONVIF-login note; image; time and NTP with a >30 s drift warning; network), plus Refresh and back links; unavailable sections and camera errors show fixed text only.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:54:03.061Z","parents":["evt-x6cbjt1gx94r"],"hash":"d450a94ebcd51f8dc26d2a00b6d5b64789a2edc46d8221e79559cf7090eeef5a"} +{"id":"evt-rxj3dgznmqjb","type":"edge.added","subject":"ver-ps9ex17","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-ps9ex17","to":"iss-s935a4n"},"at":"2026-09-19T17:54:03.062Z","parents":["evt-sw81v28rq819"],"hash":"9ad696673ed8f995f367dfea23226ed47dc0a8d1bb02b6b2f9689a7bd3bc1c67"} +{"id":"evt-pxs1rddg4b2m","type":"verification.recorded","subject":"ver-ps9ex17","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Built by a parallel agent, reviewed by Claude. src/app/cameras/[id]/{page.tsx, settings-sections.tsx, client-controls.tsx, format.ts} + 4 test files, 100% coverage on the new files. Claude's follow-up fix: the copy button now falls back to a hidden textarea + execCommand('copy') outside a secure context, because navigator.clipboard is unavailable when the app is opened over plain HTTP on a LAN IP (it would always have said 'Copy failed'); 3 new tests cover the fallback, refusal and throw paths and cleanup. New icons were added to pri-8ev5m89. Combined suite 522/522, coverage 99.89%, tsc, eslint and next build clean (the route list includes ƒ /cameras/[id]), 2026-09-19. Not yet viewed in a browser or run against the real cameras; tracked on the parent iss-cngxt98."},"at":"2026-09-19T17:54:03.063Z","parents":["evt-rxj3dgznmqjb"],"hash":"3731fef863384af1bc1597edc4c7172bd8f1c2f32b9517bd54b67067ee79360f"} +{"id":"evt-dngy3dgedbx5","type":"node.created","subject":"ver-kmkh9c4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The dashboard card's name links to /cameras/; a summary line shows the main (highest-resolution) stream, e.g. 'Main 4096×1860 · H.264 · 20 fps'; the device line hides a manufacturer of 'ONVIF' or empty ('I91ET · fw V5.8.10') and keeps real brands; no new device calls.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:54:06.076Z","parents":["evt-pxs1rddg4b2m"],"hash":"0d08c24227697ca72921d7e64073e7cfee04de903975e550ea41bc0bbe20bd2e"} +{"id":"evt-jv9fdfdw5cwz","type":"edge.added","subject":"ver-kmkh9c4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-kmkh9c4","to":"iss-pv2bvzj"},"at":"2026-09-19T17:54:06.079Z","parents":["evt-dngy3dgedbx5"],"hash":"5ab0761924edb26f38a82b9a21374e2745d8f8f93620f6102ddcb893bbb5a46e"} +{"id":"evt-1y4b5zgp33ph","type":"verification.recorded","subject":"ver-kmkh9c4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Built by a parallel agent, reviewed by Claude. src/app/camera-summary.ts (pure: mainStream, codecLabel, streamSummary, displayBrand, deviceLine) with camera-summary.test.ts, plus 3 new tests in camera-card.test.tsx (link href; probe-like profiles give 'I91ET · fw V5.8.10' and the summary, with no 'ONVIF'; nothing before info loads). Existing 'Hikvision DS-2CD · fw V5.7' test unchanged and passing. Summary covers the main stream only; no '+N more'. Combined suite 522/522, 2026-09-19. Not yet viewed in a browser."},"at":"2026-09-19T17:54:06.080Z","parents":["evt-jv9fdfdw5cwz"],"hash":"72f691f9f289c71e6c32284ec53f121784047b70f7676a778f510c48b6629fb9"} +{"id":"evt-2qgsw9d64b3g","type":"node.status_changed","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:54:07.127Z","parents":["evt-1y4b5zgp33ph"],"hash":"4bd51a8c2627040dad1b79f354d51b6916280f4ff051548af1f1152340cf3bd3"} +{"id":"evt-t8jhasgkfb5a","type":"node.status_changed","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:54:08.288Z","parents":["evt-2qgsw9d64b3g"],"hash":"c030d4aa2a94fad669d5c2cfbc72b347f8f1fd607def5c66bcf50d72d34feaae"} +{"id":"evt-wbe1mkb54hck","type":"node.created","subject":"ver-1fbexnj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser against both real cameras: open a camera's page from its name on the dashboard; the four sections show the values the probe found; Copy puts the RTSP URL on the clipboard (over the LAN URL you normally use); Refresh works; the card shows the summary line and 'I91ET · fw V5.8.10'.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:54:09.508Z","parents":["evt-t8jhasgkfb5a"],"hash":"02a1c3a22a3890dbd88b40d3248254a59deb780d5a756418360a839fd2d6b5ac"} +{"id":"evt-hmkqz65wra0a","type":"edge.added","subject":"ver-1fbexnj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-1fbexnj","to":"iss-cngxt98"},"at":"2026-09-19T17:54:09.511Z","parents":["evt-wbe1mkb54hck"],"hash":"dd94aefa435b1a8a0325f8da753a2610a8fd27316d3671604e445b469109f0dd"} +{"id":"evt-mxv72kxw35fw","type":"verification.recorded","subject":"ver-1fbexnj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"Awaiting the user's manual check; Claude doesn't run dev servers here, and this module has only been exercised against fakes shaped like the probe results."},"at":"2026-09-19T17:54:09.512Z","parents":["evt-hmkqz65wra0a"],"hash":"eb0547b2d061037e896cf001df716bdba9cced08b4e3c24dac4fb9c93258a917"} +{"id":"evt-9syzrzp8ng8g","type":"node.created","subject":"ver-sgvq7se","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser against both real cameras: open a camera's page from its name on the dashboard; the four sections show the values the probe found; Copy puts the RTSP URL on the clipboard (over the LAN URL you normally use); Refresh works; the card shows the summary line and 'I91ET · fw V5.8.10'.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:58:41.419Z","parents":["evt-mxv72kxw35fw"],"hash":"9dda8fb9a81aaa525b37c2ee0d094865400e814502032862388d9c60478f7a31"} +{"id":"evt-42geta3461gv","type":"edge.added","subject":"ver-sgvq7se","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-sgvq7se","to":"iss-cngxt98"},"at":"2026-09-19T17:58:41.421Z","parents":["evt-9syzrzp8ng8g"],"hash":"f5e9b08be9d7db2f9adad826e1ba44608c6c33478e83089cc918232db408caa4"} +{"id":"evt-k2c8c78v7jzf","type":"verification.recorded","subject":"ver-sgvq7se","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual check by the user (Michael Mainguy) on 2026-09-19: \"checked, looks good\". This resolves pending ver-1fbexnj."},"at":"2026-09-19T17:58:41.422Z","parents":["evt-42geta3461gv"],"hash":"6e4b445b64c8448f454780a1dc817cd83e8b61394369e436f484d72d2c54252a"} +{"id":"evt-9nwcj1gp32rt","type":"node.status_changed","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:58:42.661Z","parents":["evt-k2c8c78v7jzf"],"hash":"596c33315f0e7cfae98d4dc3e8cdcec9baa15d45726580d7e88373e6ddceac1c"} diff --git a/src/app/camera-card.test.tsx b/src/app/camera-card.test.tsx index 93bde45..60f5c76 100644 --- a/src/app/camera-card.test.tsx +++ b/src/app/camera-card.test.tsx @@ -248,6 +248,41 @@ describe("CameraCard", () => { }); }); +describe("name link and summary", () => { + it("links the camera name to its page", async () => { + stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame }); + renderWithQuery(); + const link = screen.getByRole("link", { name: "Porch" }); + expect(link.getAttribute("href")).toBe(`/cameras/${cam.id}`); + await screen.findByText("Hikvision DS-2CD · fw V5.7"); + }); + + it("summarizes the main stream and hides the ONVIF placeholder brand", async () => { + const probeInfo = { + manufacturer: "ONVIF", + model: "I91ET", + firmwareVersion: "V5.8.10", + profiles: [ + { token: "Profile_1", name: "mainStream", encoding: "H264", width: 4096, height: 1860, fps: 20 }, + { token: "Profile_2", name: "subStream", encoding: "H264", width: 1200, height: 536, fps: 20 }, + ], + }; + stubFetch({ [`GET ${base}/info`]: () => json(probeInfo), [`GET ${base}/snapshot`]: frame }); + renderWithQuery(); + + expect(await screen.findByText("I91ET · fw V5.8.10")).toBeTruthy(); + expect(screen.getByText("Main 4096×1860 · H.264 · 20 fps")).toBeTruthy(); + expect(screen.queryByText(/ONVIF/)).toBeNull(); + }); + + it("shows no summary or device line before info loads", () => { + stubFetch({ [`GET ${base}/info`]: () => new Promise(() => {}) }); + renderWithQuery(); + expect(screen.queryByText(/^Main /)).toBeNull(); + expect(screen.queryByText(/ · fw /)).toBeNull(); + }); +}); + describe("status badge", () => { const badge = () => document.querySelector("[data-status]")!; diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx index b398a3a..02cdf62 100644 --- a/src/app/camera-card.tsx +++ b/src/app/camera-card.tsx @@ -11,6 +11,7 @@ import { Trash2, TriangleAlert, } from "lucide-react"; +import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import type { CameraSummary } from "@/lib/camera-registry"; import { @@ -23,6 +24,7 @@ import { type CameraProblem, } from "./camera-queries"; import { cameraStatus, StatusBadge } from "./camera-status"; +import { deviceLine, streamSummary } from "./camera-summary"; const DEFAULT_USERNAME = "camera"; @@ -230,6 +232,9 @@ export default function CameraCard({ resetCamera(); } + const device = info.data ? deviceLine(info.data) : null; + const summary = info.data ? streamSummary(info.data.profiles) : null; + const showSetup = problem?.kind === "inactive"; const showLogin = !showSetup && (problem?.kind === "auth" || editingLogin); @@ -239,7 +244,9 @@ export default function CameraCard({
- {cam.name ?? info.data?.model ?? "Unnamed camera"} + + {cam.name ?? info.data?.model ?? "Unnamed camera"} + @@ -261,11 +268,8 @@ export default function CameraCard({ )}
- {info.data?.manufacturer && ( -
- {info.data.manufacturer} {info.data.model} · fw {info.data.firmwareVersion} -
- )} + {device &&
{device}
} + {summary &&
{summary}
} {cam.location && (
{cam.location}
)} diff --git a/src/app/camera-summary.test.ts b/src/app/camera-summary.test.ts new file mode 100644 index 0000000..80f99cb --- /dev/null +++ b/src/app/camera-summary.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { CameraProfile } from "@/lib/camera"; +import { codecLabel, deviceLine, displayBrand, mainStream, streamSummary } from "./camera-summary"; + +const main: CameraProfile = { token: "p1", name: "mainStream", encoding: "H264", width: 4096, height: 1860, fps: 20 }; +const sub: CameraProfile = { token: "p2", name: "subStream", encoding: "H264", width: 1200, height: 536, fps: 20 }; + +describe("mainStream", () => { + it("picks the highest resolution regardless of order", () => { + expect(mainStream([sub, main])).toBe(main); + expect(mainStream([main, sub])).toBe(main); + }); + + it("keeps the first on a tie, and treats missing sizes as 0", () => { + const twin = { ...main, token: "p3" }; + expect(mainStream([main, twin])).toBe(main); + const bare = { token: "b" }; + expect(mainStream([bare, sub])).toBe(sub); + expect(mainStream([bare])).toBe(bare); + }); + + it("is undefined with no profiles", () => { + expect(mainStream([])).toBeUndefined(); + }); +}); + +describe("streamSummary", () => { + it("describes the main stream of the probed cameras", () => { + expect(streamSummary([main, sub])).toBe("Main 4096×1860 · H.264 · 20 fps"); + }); + + it("omits missing parts", () => { + expect(streamSummary([{ token: "x", encoding: "H265" }])).toBe("Main · H.265"); + expect(streamSummary([{ token: "x", width: 640, height: 480 }])).toBe("Main 640×480"); + expect(streamSummary([{ token: "x" }])).toBe("Main"); + }); + + it("is null with no profiles", () => { + expect(streamSummary([])).toBeNull(); + }); +}); + +describe("codecLabel", () => { + it.each([ + ["H264", "H.264"], + ["h265", "H.265"], + ["JPEG", "JPEG"], + ["MPEG4", "MPEG4"], + ])("%s → %s", (encoding, label) => { + expect(codecLabel(encoding)).toBe(label); + }); +}); + +describe("displayBrand", () => { + it.each([ + ["ONVIF", undefined], + ["onvif ", undefined], + ["", undefined], + [" ", undefined], + [undefined, undefined], + ["Hikvision", "Hikvision"], + [" Annke ", "Annke"], + ])("%j → %j", (manufacturer, brand) => { + expect(displayBrand(manufacturer)).toBe(brand); + }); +}); + +describe("deviceLine", () => { + it("hides the ONVIF placeholder brand", () => { + expect(deviceLine({ manufacturer: "ONVIF", model: "I91ET", firmwareVersion: "V5.8.10" })).toBe( + "I91ET · fw V5.8.10", + ); + }); + + it("keeps a real brand", () => { + expect(deviceLine({ manufacturer: "Hikvision", model: "DS-2CD", firmwareVersion: "V5.7" })).toBe( + "Hikvision DS-2CD · fw V5.7", + ); + }); + + it("omits missing parts", () => { + expect(deviceLine({ model: "I91ET" })).toBe("I91ET"); + expect(deviceLine({ firmwareVersion: "V1" })).toBe("fw V1"); + expect(deviceLine({ manufacturer: "Annke" })).toBe("Annke"); + }); + + it("is null when nothing is known", () => { + expect(deviceLine({})).toBeNull(); + expect(deviceLine({ manufacturer: "ONVIF" })).toBeNull(); + }); +}); diff --git a/src/app/camera-summary.ts b/src/app/camera-summary.ts new file mode 100644 index 0000000..4d12a84 --- /dev/null +++ b/src/app/camera-summary.ts @@ -0,0 +1,48 @@ +import type { CameraInfo, CameraProfile } from "@/lib/camera"; + +/** + * One-line descriptions for a dashboard card, built only from info the card already has + * (vrek dec-gtw493s). Pure functions, no device calls. + */ + +/** The highest-resolution profile (first wins a tie), or undefined with no profiles. */ +export function mainStream(profiles: CameraProfile[]): CameraProfile | undefined { + let best: CameraProfile | undefined; + for (const p of profiles) { + const area = (p.width ?? 0) * (p.height ?? 0); + if (!best || area > (best.width ?? 0) * (best.height ?? 0)) best = p; + } + return best; +} + +const CODEC_LABELS: Record = { H264: "H.264", H265: "H.265" }; + +export function codecLabel(encoding: string): string { + return CODEC_LABELS[encoding.toUpperCase()] ?? encoding; +} + +/** e.g. "Main 4096×1860 · H.264 · 20 fps"; null with no profiles. */ +export function streamSummary(profiles: CameraProfile[]): string | null { + const main = mainStream(profiles); + if (!main) return null; + const parts = [ + main.width && main.height ? `Main ${main.width}×${main.height}` : "Main", + main.encoding ? codecLabel(main.encoding) : undefined, + main.fps ? `${main.fps} fps` : undefined, + ]; + return parts.filter(Boolean).join(" · "); +} + +/** The manufacturer, unless it's empty or the placeholder "ONVIF" (vrek fnd-msm9prc). */ +export function displayBrand(manufacturer: string | undefined): string | undefined { + const brand = manufacturer?.trim(); + return brand && brand.toUpperCase() !== "ONVIF" ? brand : undefined; +} + +/** e.g. "Hikvision DS-2CD · fw V5.7" or "I91ET · fw V5.8.10"; null if nothing is known. */ +export function deviceLine(info: Pick): string | null { + const name = [displayBrand(info.manufacturer), info.model].filter(Boolean).join(" "); + const parts = [name || undefined, info.firmwareVersion ? `fw ${info.firmwareVersion}` : undefined]; + const line = parts.filter(Boolean).join(" · "); + return line || null; +} diff --git a/src/app/cameras/[id]/client-controls.test.tsx b/src/app/cameras/[id]/client-controls.test.tsx new file mode 100644 index 0000000..cf19d6a --- /dev/null +++ b/src/app/cameras/[id]/client-controls.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const refresh = vi.fn(); +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) })); + +const { CopyButton, LocalTime, RefreshButton } = await import("./client-controls"); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +beforeEach(() => refresh.mockReset()); + +const iconIn = (el: Element) => + [...(el.querySelector("svg")?.getAttribute("class") ?? "").matchAll(/lucide-([a-z0-9-]+)/g)].map((m) => m[1]); + +describe("RefreshButton", () => { + it("re-renders the server page to re-read the camera", () => { + render(); + const button = screen.getByRole("button", { name: "Refresh" }); + expect(iconIn(button)).toContain("refresh-cw"); + fireEvent.click(button); + expect(refresh).toHaveBeenCalledTimes(1); + expect(button.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true"); + }); +}); + +describe("CopyButton", () => { + const URL_TEXT = "rtsp://192.168.17.129/Streaming/Channels/101?transportmode=unicast"; + + const setSecureContext = (value: boolean) => + Object.defineProperty(window, "isSecureContext", { value, configurable: true }); + const stubExecCommand = (result: boolean) => { + const exec = vi.fn(() => { + // What's selected at copy time is what lands on the clipboard. + copiedText = document.querySelector("textarea")?.value ?? null; + return result; + }); + Object.defineProperty(document, "execCommand", { value: exec, configurable: true }); + return exec; + }; + let copiedText: string | null = null; + + it("copies the URL, says so, then resets after two seconds", async () => { + setSecureContext(true); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + vi.useFakeTimers(); + render(); + + const button = screen.getByRole("button", { name: "Copy" }); + expect(iconIn(button)).toContain("copy"); + await act(async () => { + fireEvent.click(button); + }); + expect(writeText).toHaveBeenCalledWith(URL_TEXT); + expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy(); + expect(iconIn(screen.getByRole("button", { name: "Copied" }))).toContain("check"); + + act(() => vi.advanceTimersByTime(2000)); + expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy(); + }); + + it("still copies over plain HTTP on the LAN, via a temporary text field", async () => { + setSecureContext(false); + Object.assign(navigator, { clipboard: undefined }); + const exec = stubExecCommand(true); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy()); + expect(exec).toHaveBeenCalledWith("copy"); + expect(copiedText).toBe(URL_TEXT); + expect(document.querySelector("textarea")).toBeNull(); + }); + + it("uses the fallback when a secure page has no clipboard API", async () => { + setSecureContext(true); + Object.assign(navigator, { clipboard: undefined }); + const exec = stubExecCommand(true); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy()); + expect(exec).toHaveBeenCalled(); + }); + + it.each([ + ["the browser refuses the copy command", () => stubExecCommand(false)], + [ + "the copy command throws", + () => + Object.defineProperty(document, "execCommand", { + value: () => { + throw new Error("not allowed"); + }, + configurable: true, + }), + ], + ])("says Copy failed when %s, and cleans up", async (_label, arrange) => { + setSecureContext(false); + arrange(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Copy failed" })).toBeTruthy()); + expect(document.querySelector("textarea")).toBeNull(); + }); +}); + +describe("LocalTime", () => { + const iso = "2026-09-19T17:42:15.000Z"; + + it("renders the ISO time on the server, where the viewer's locale is unknown", () => { + expect(renderToString()).toBe(``); + }); + + it("shows local time in the browser, keeping the machine-readable value", () => { + render(); + const time = document.querySelector("time")!; + expect(time.getAttribute("datetime")).toBe(iso); + expect(time.textContent).toBe(new Date(iso).toLocaleString()); + }); +}); diff --git a/src/app/cameras/[id]/client-controls.tsx b/src/app/cameras/[id]/client-controls.tsx new file mode 100644 index 0000000..0746dce --- /dev/null +++ b/src/app/cameras/[id]/client-controls.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Check, Copy, LoaderCircle, RefreshCw } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useSyncExternalStore, useTransition } from "react"; + +/** Re-reads the camera's settings by re-rendering the server page (no polling). */ +export function RefreshButton() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + return ( + + ); +} + +/** + * Copies text. The async clipboard API only exists on HTTPS or localhost, and this app is + * usually opened over plain HTTP on the LAN, so fall back to copying from a hidden + * textarea, which browsers still allow in response to a click. + */ +export async function writeClipboard(text: string): Promise { + if (window.isSecureContext && navigator.clipboard) { + await navigator.clipboard.writeText(text); + return; + } + const area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.focus(); + area.select(); + try { + // Deprecated, but the only option outside a secure context. + if (!document.execCommand("copy")) throw new Error("copy command refused"); + } finally { + area.remove(); + } +} + +/** Copies a stream URL; says so for two seconds. */ +export function CopyButton({ text }: { text: string }) { + const [state, setState] = useState<"idle" | "copied" | "failed">("idle"); + + useEffect(() => { + if (state === "idle") return; + const timer = setTimeout(() => setState("idle"), 2000); + return () => clearTimeout(timer); + }, [state]); + + async function copy() { + try { + await writeClipboard(text); + setState("copied"); + } catch { + setState("failed"); + } + } + + return ( + + ); +} + +const noSubscription = () => () => {}; + +/** + * The camera's clock in the viewer's own locale and time zone. The server can't know the + * viewer's locale, so it renders the ISO timestamp and the browser swaps in local time. + */ +export function LocalTime({ iso }: { iso: string }) { + const inBrowser = useSyncExternalStore(noSubscription, () => true, () => false); + return ; +} diff --git a/src/app/cameras/[id]/format.test.ts b/src/app/cameras/[id]/format.test.ts new file mode 100644 index 0000000..3e79a7e --- /dev/null +++ b/src/app/cameras/[id]/format.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + bitrateLabel, + codecLabel, + dayNightLabel, + DRIFT_WARNING_SECONDS, + driftIsLarge, + driftLabel, + linkLabel, + ntpLabel, + resolutionLabel, + titleCase, + valueOrUnknown, +} from "./format"; + +describe("format", () => { + it.each([ + ["H264", "Main", "H.264 Main"], + ["H265", undefined, "H.265"], + ["h264", undefined, "H.264"], + ["JPEG", undefined, "JPEG"], + [undefined, undefined, "Unknown codec"], + ])("codecLabel(%j, %j) → %s", (codec, profile, label) => { + expect(codecLabel(codec, profile)).toBe(label); + }); + + it("formats resolution", () => { + expect(resolutionLabel(4096, 1860)).toBe("4096×1860"); + expect(resolutionLabel(undefined, 1860)).toBe("Unknown"); + }); + + it.each([ + [10240, false, "10,240 kbps · variable"], + [4096, true, "4,096 kbps · constant"], + [512, undefined, "512 kbps"], + [undefined, false, "Unknown bitrate · variable"], + ])("bitrateLabel(%j, %j) → %s", (kbps, cbr, label) => { + expect(bitrateLabel(kbps, cbr)).toBe(label); + }); + + it.each([ + ["AUTO", "Auto"], + ["OFF", "Off"], + ["IR_CUT_AUTO", "Ir cut auto"], + [undefined, "Unknown"], + ["", "Unknown"], + ])("titleCase(%j) → %s", (mode, label) => { + expect(titleCase(mode)).toBe(label); + }); + + it.each([ + ["AUTO", "Auto"], + ["ON", "Day (IR-cut on)"], + ["OFF", "Night (IR-cut off)"], + ["off", "Night (IR-cut off)"], + ["SCHEDULE", "Schedule"], + [undefined, "Unknown"], + ])("dayNightLabel(%j) → %s", (mode, label) => { + expect(dayNightLabel(mode)).toBe(label); + }); + + it("describes drift and flags more than 30 s", () => { + expect(driftLabel(0)).toBe("in sync (0 s)"); + expect(driftLabel(-1)).toBe("−1 s behind"); + expect(driftLabel(45)).toBe("+45 s ahead"); + expect(DRIFT_WARNING_SECONDS).toBe(30); + expect(driftIsLarge(30)).toBe(false); + expect(driftIsLarge(-30)).toBe(false); + expect(driftIsLarge(31)).toBe(true); + expect(driftIsLarge(-31)).toBe(true); + }); + + it("describes NTP", () => { + expect(ntpLabel({ source: "manual", servers: ["time.windows.com"] })).toBe("Manual: time.windows.com"); + expect(ntpLabel({ source: "dhcp", servers: ["192.168.16.1", "fe80::1"] })).toBe( + "From DHCP: 192.168.16.1, fe80::1", + ); + expect(ntpLabel({ source: "manual", servers: [] })).toBe("Manual: no server set"); + expect(ntpLabel(null)).toBe("Not reported"); + }); + + it("describes the link", () => { + expect(linkLabel(100, "Full")).toBe("100 Mb/s Full duplex"); + expect(linkLabel(1000)).toBe("1000 Mb/s"); + expect(linkLabel(undefined, "Half")).toBe("Half duplex"); + expect(linkLabel()).toBe("Unknown"); + }); + + it("shows unknown values", () => { + expect(valueOrUnknown(0)).toBe("0"); + expect(valueOrUnknown(0.5)).toBe("0.5"); + expect(valueOrUnknown(undefined)).toBe("Unknown"); + }); +}); diff --git a/src/app/cameras/[id]/format.ts b/src/app/cameras/[id]/format.ts new file mode 100644 index 0000000..162d6da --- /dev/null +++ b/src/app/cameras/[id]/format.ts @@ -0,0 +1,74 @@ +/** + * Display formatting for the camera settings page. Pure functions, no React, so they are + * trivially testable and shared by server and client components. + */ + +/** "H264" → "H.264", "H265" → "H.265"; other codecs as reported. */ +export function codecLabel(codec?: string, profile?: string): string { + const name = codec?.replace(/^H(26[45])$/i, "H.$1") ?? "Unknown codec"; + return profile ? `${name} ${profile}` : name; +} + +export function resolutionLabel(width?: number, height?: number): string { + return width && height ? `${width}×${height}` : "Unknown"; +} + +/** "10,240 kbps · variable" */ +export function bitrateLabel(kbps?: number, constantBitRate?: boolean): string { + const rate = kbps === undefined ? "Unknown bitrate" : `${kbps.toLocaleString("en-US")} kbps`; + if (constantBitRate === undefined) return rate; + return `${rate} · ${constantBitRate ? "constant" : "variable"}`; +} + +/** "AUTO" → "Auto", "OFF" → "Off", "IR_CUT" → "Ir cut". */ +export function titleCase(mode?: string): string { + if (!mode) return "Unknown"; + const words = mode.replace(/_/g, " ").toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** + * ONVIF IrCutFilter: ON = filter in place = colour (day) image; OFF = filter removed = + * night (IR) image; AUTO = the camera switches. + */ +export function dayNightLabel(irCut?: string): string { + switch (irCut?.toUpperCase()) { + case "AUTO": + return "Auto"; + case "ON": + return "Day (IR-cut on)"; + case "OFF": + return "Night (IR-cut off)"; + default: + return titleCase(irCut); + } +} + +/** Drift beyond this many seconds is worth a warning (vrek dec-gtw493s). */ +export const DRIFT_WARNING_SECONDS = 30; + +export function driftLabel(seconds: number): string { + if (seconds === 0) return "in sync (0 s)"; + return `${seconds > 0 ? "+" : "−"}${Math.abs(seconds)} s ${seconds > 0 ? "ahead" : "behind"}`; +} + +export function driftIsLarge(seconds: number): boolean { + return Math.abs(seconds) > DRIFT_WARNING_SECONDS; +} + +export function ntpLabel(ntp: { source: "dhcp" | "manual"; servers: string[] } | null): string { + if (!ntp) return "Not reported"; + const servers = ntp.servers.length ? ntp.servers.join(", ") : "no server set"; + return `${ntp.source === "dhcp" ? "From DHCP" : "Manual"}: ${servers}`; +} + +export function linkLabel(speedMbps?: number, duplex?: string): string { + const parts = []; + if (speedMbps !== undefined) parts.push(`${speedMbps} Mb/s`); + if (duplex) parts.push(`${duplex} duplex`); + return parts.length ? parts.join(" ") : "Unknown"; +} + +export function valueOrUnknown(value?: number | string): string { + return value === undefined ? "Unknown" : String(value); +} diff --git a/src/app/cameras/[id]/page.test.tsx b/src/app/cameras/[id]/page.test.tsx new file mode 100644 index 0000000..aff14ed --- /dev/null +++ b/src/app/cameras/[id]/page.test.tsx @@ -0,0 +1,90 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CameraRecord } from "@/lib/camera-registry"; + +const requirePageAccess = vi.fn(); +vi.mock("@/lib/access", () => ({ requirePageAccess })); + +const getCameraRecord = vi.fn<(id: string) => Promise>(); +vi.mock("@/lib/camera-registry", async (importOriginal) => ({ + ...(await importOriginal()), + getCameraRecord, +})); + +// The async sections are tested on their own; here they are a marker carrying the target. +vi.mock("./settings-sections", () => ({ + CameraSettingsSections: ({ target }: { target: object }) => ( +
{JSON.stringify(target)}
+ ), +})); + +class NotFound extends Error {} +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new NotFound(); + }, + useRouter: () => ({ refresh: vi.fn() }), +})); + +const { default: CameraPage } = await import("./page"); + +const ID = "22ec0000-8b90-11b5-845d-d03bf404af2e"; +const record: CameraRecord = { + id: ID, + urn: `urn:uuid:${ID}`, + host: "192.168.17.129", + port: 80, + name: "I91ET", + location: "Front door", + lastSeen: "2026-09-19T00:00:00.000Z", +}; + +const page = (id = ID) => CameraPage({ params: Promise.resolve({ id }), searchParams: Promise.resolve({}) }); + +beforeEach(() => { + requirePageAccess.mockReset().mockResolvedValue(undefined); + getCameraRecord.mockReset().mockResolvedValue(record); +}); + +describe("/cameras/[id]", () => { + it("checks access before anything else", async () => { + requirePageAccess.mockRejectedValue(new Error("redirect /login")); + await expect(page()).rejects.toThrow("redirect /login"); + expect(getCameraRecord).not.toHaveBeenCalled(); + }); + + it("renders the header, controls, and the sections for the registry address", async () => { + const html = renderToStaticMarkup(await page()); + expect(requirePageAccess).toHaveBeenCalled(); + expect(getCameraRecord).toHaveBeenCalledWith(ID); + expect(html).toContain("I91ET"); + expect(html).toContain("192.168.17.129:80"); + expect(html).toContain("Front door"); + expect(html).toContain('href="/"'); + expect(html).toContain("All cameras"); + expect(html).toContain(">Refresh"); + expect(html).toContain(JSON.stringify({ id: ID, host: "192.168.17.129", port: 80 }).replace(/"/g, """)); + for (const svg of html.match(/]*>/g)!) expect(svg).toContain('aria-hidden="true"'); + }); + + it("names an unnamed camera, without a location line", async () => { + getCameraRecord.mockResolvedValue({ ...record, name: undefined, location: undefined }); + const html = renderToStaticMarkup(await page()); + expect(html).toContain("Unnamed camera"); + expect(html).not.toContain(" · "); + }); + + it.each([ + ["a malformed id", "../../etc", null], + ["an unknown camera", ID, null], + ["a registry entry outside the private network", ID, { ...record, host: "8.8.8.8" }], + ])("is not found for %s", async (_l, id, found) => { + getCameraRecord.mockResolvedValue(found); + await expect(page(id)).rejects.toBeInstanceOf(NotFound); + }); + + it("never looks up a malformed id", async () => { + await expect(page("not-an-id")).rejects.toBeInstanceOf(NotFound); + expect(getCameraRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/cameras/[id]/page.tsx b/src/app/cameras/[id]/page.tsx new file mode 100644 index 0000000..84470bb --- /dev/null +++ b/src/app/cameras/[id]/page.tsx @@ -0,0 +1,58 @@ +import { ArrowLeft, Cctv, LoaderCircle } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Suspense } from "react"; +import { requirePageAccess } from "@/lib/access"; +import { isAllowedHost } from "@/lib/camera"; +import { getCameraRecord, isValidCameraId } from "@/lib/camera-registry"; +import { RefreshButton } from "./client-controls"; +import { CameraSettingsSections } from "./settings-sections"; + +/** + * One camera's read-only settings (vrek gol-2nxgqjn, iss-s935a4n). The camera address comes + * from the server-side registry, never from the request, and must be a private IPv4 + * address, so this page can't be used to reach arbitrary hosts (pri-m1csgrm). + */ +export default async function CameraPage({ params }: PageProps<"/cameras/[id]">) { + await requirePageAccess(); + const { id } = await params; + if (!isValidCameraId(id)) notFound(); + const record = await getCameraRecord(id); + if (!record || !isAllowedHost(record.host)) notFound(); + const target = { id: record.id, host: record.host, port: record.port }; + + return ( +
+ + + All cameras + +
+
+

+ + {record.name ?? "Unnamed camera"} +

+

+ {record.host}:{record.port} + {record.location ? · {record.location} : null} +

+
+ +
+ +
+ + + Reading settings from the camera… +

+ } + > + +
+
+
+ ); +} diff --git a/src/app/cameras/[id]/settings-sections.test.tsx b/src/app/cameras/[id]/settings-sections.test.tsx new file mode 100644 index 0000000..bd3c803 --- /dev/null +++ b/src/app/cameras/[id]/settings-sections.test.tsx @@ -0,0 +1,252 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CameraSettings } from "@/lib/camera-settings"; + +/** + * Renders the settings sections with the camera reader faked (no real camera, pri-e14bahk), + * using the values the real I91ET cameras reported in the probe (vrek fnd-j1s725e, + * fnd-g4z7c6d, fnd-b73cav4). + */ +const getCameraSettings = vi.fn<() => Promise>(); +vi.mock("@/lib/camera-settings", () => ({ getCameraSettings })); + +const { CameraSettingsSections, webUiUrl } = await import("./settings-sections"); +const { CameraAuthError, CameraInactiveError } = await import("@/lib/camera"); +const { CredentialStoreError } = await import("@/lib/credential-store"); + +const target = { id: "cam", host: "192.168.17.129", port: 80 }; +const LEAK = "SOAP fault from http://admin:hunter2@192.168.17.129/onvif"; + +const probe: CameraSettings = { + streams: { + ok: true, + value: [ + { + token: "Profile_1", + name: "mainStream", + codec: "H264", + profile: "Main", + width: 4096, + height: 1860, + fps: 20, + bitrateKbps: 10240, + constantBitRate: false, + gop: 50, + quality: 3, + rtspUrl: "rtsp://192.168.17.129/Streaming/Channels/101?transportmode=unicast", + }, + { token: "Profile_2", rtspUrl: null }, + ], + }, + image: { + ok: true, + value: { + dayNight: "AUTO", + exposure: "AUTO", + whiteBalance: "AUTO", + wideDynamicRange: "OFF", + backlightCompensation: "OFF", + brightness: 50, + contrast: 50, + saturation: 50, + sharpness: 50, + noiseReduction: 0.5, + }, + }, + time: { + ok: true, + value: { + cameraTime: "2026-09-19T17:42:15.000Z", + driftSeconds: -1, + ntp: { source: "manual", servers: ["time.windows.com"] }, + }, + }, + network: { + ok: true, + value: { + interfaces: [ + { + name: "eth0", + mac: "d0:3b:f4:04:af:2e", + enabled: true, + ipv4: { address: "192.168.17.129", prefixLength: 20, dhcp: true }, + ipv6: [ + { address: "fe80::d23b:f4ff:fe04:af2e", prefixLength: 64 }, + { address: "2001:470:1f11:137:d23b:f4ff:fe04:af2e" }, + ], + speedMbps: 100, + duplex: "Full", + }, + { ipv6: [] }, + ], + gateways: ["192.168.16.1"], + }, + }, +}; + +async function render(settings: Partial = {}) { + getCameraSettings.mockResolvedValue({ ...probe, ...settings }); + return renderToStaticMarkup(await CameraSettingsSections({ target })); +} + +/** The text of a definition row: "
label
value
". */ +const row = (html: string, label: string) => + new RegExp(`]*>${label}
(.*?)
`).exec(html)?.[1].replace(/<[^>]+>/g, ""); + +beforeEach(() => { + getCameraSettings.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +describe("CameraSettingsSections", () => { + it("reads the camera it was given", async () => { + await render(); + expect(getCameraSettings).toHaveBeenCalledWith(target); + }); + + it("shows streams with codec, resolution, rate, bitrate, GOP, quality and a copyable URL", async () => { + const html = await render(); + expect(html).toContain("mainStream"); + expect(row(html, "Codec")).toBe("H.264 Main"); + expect(row(html, "Resolution")).toBe("4096×1860"); + expect(row(html, "Frame rate")).toBe("20 fps"); + expect(row(html, "Bitrate")).toBe("10,240 kbps · variable"); + expect(row(html, "Keyframe interval")).toBe("50 frames"); + expect(row(html, "Quality")).toBe("3"); + expect(html).toContain( + 'rtsp://192.168.17.129/Streaming/Channels/101?transportmode=unicast', + ); + expect(html).toContain(">Copy"); + expect(html).toContain("sign in with the camera's ONVIF login (the one saved for this camera)"); + }); + + it("names a stream by token and marks unknowns when the camera says little", async () => { + const html = await render(); + expect(html).toContain(">Profile_2"); + expect(html).toContain("No stream URL reported"); + expect(html).toContain("Unknown codec"); + expect(html).toContain("Unknown bitrate"); + }); + + it("shows image settings", async () => { + const html = await render(); + expect(row(html, "Day/night")).toBe("Auto"); + expect(row(html, "Exposure")).toBe("Auto"); + expect(row(html, "Wide dynamic range")).toBe("Off"); + expect(row(html, "Backlight compensation")).toBe("Off"); + expect(row(html, "Brightness")).toBe("50"); + expect(row(html, "Noise reduction")).toBe("0.5"); + }); + + it("shows the camera clock, drift and NTP", async () => { + const html = await render(); + expect(html).toContain('