From b55ab9688182f15d275086bf44bcd74ff6927994 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 01:31:37 +0000 Subject: [PATCH 1/2] open a Camera's SIMULATED picture from its row in the Sandbox (#117) --- dashboard/AGENTS.md | 1 + dashboard/src/App.css | 34 +++++++++ dashboard/src/App.tsx | 1 + dashboard/src/CameraLine.tsx | 50 +++++++++++++ dashboard/src/ReadingsPanel.tsx | 33 +++++++-- dashboard/src/cameraPictureFlow.test.tsx | 94 ++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 dashboard/src/CameraLine.tsx create mode 100644 dashboard/src/cameraPictureFlow.test.tsx diff --git a/dashboard/AGENTS.md b/dashboard/AGENTS.md index 58df3c2..a330c68 100644 --- a/dashboard/AGENTS.md +++ b/dashboard/AGENTS.md @@ -24,3 +24,4 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m - **The coast Water ETA is built on the device** (`coastWaterEta.ts`, `useCoastWaterEta.ts`): the home's ground comes from the pack's elevation tile (`groundHeight.ts`), the rate from the nearest Registered Node's levels kept by Event time, and the Region's `storm_surge` summary (`rising_window_s`, `flood_prone_below_cm`) from `/api/state`. A flow test stubs `createImageBitmap` and `OffscreenCanvas` to give a tile pixel; ground above `flood_prone_below_cm` (9 ft at Fort Myers) has no ETA, so pick test ground below it. - **Vitest runs under Node 22 in CI; test locally with it** (`npx --yes node@22 node_modules/vitest/vitest.mjs run`). A fetch stub must not build a `Response` from a jsdom `Blob` (`object.stream is not a function` on Node 22); give it a string body. A flow test mutes `console.error`, so print it when a test fails without a clue. - **A Measurement kind is data, not a property name.** `kindLabel` and `measurementInDisplayUnit` look kinds up with `Object.hasOwn`, so `constructor` and `__proto__` read as their own names; only a known kind in its wire unit is converted (`displayUnits.ts`, `CONVERSIONS`), anything else is shown as sent. `camera` has no trend arrow (`readingTrends.ts`). A test needs `JSON.parse` to make an own `__proto__` key. +- **A Camera line opens its picture through an `` whose URL carries the picture number** (`CameraLine.tsx`), so the browser refetches when it changes; the Sandbox serves any Sensor's, anywhere else only this Node's own `/api/camera/picture`. diff --git a/dashboard/src/App.css b/dashboard/src/App.css index c39e42e..05d3463 100644 --- a/dashboard/src/App.css +++ b/dashboard/src/App.css @@ -1741,6 +1741,40 @@ section p.flood-prone-caption { overflow-wrap: anywhere; } +.camera-toggle { + min-height: 2.5rem; + padding: 0; + border: 0; + background: none; + color: var(--accent); + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +.camera-picture { + margin: 0.3rem 0 0.2rem; +} + +.camera-picture img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--border); + border-radius: 0.25rem; +} + +.camera-picture figcaption { + margin-top: 0.3rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.camera-picture .tag { + margin-left: 0; + margin-right: 0.3rem; +} + .slot-value.tick { animation: slot-flash 600ms ease-out 1; } diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 2bd8b53..2f424c2 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -265,6 +265,7 @@ export function App() { rises={rises} trends={trends} panel={panel("readings")} + inSandbox={sandbox !== null} /> ); const sandboxPanel = sandbox && ( diff --git a/dashboard/src/CameraLine.tsx b/dashboard/src/CameraLine.tsx new file mode 100644 index 0000000..90901d0 --- /dev/null +++ b/dashboard/src/CameraLine.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; + +import { ReadingSlot } from "./ReadingSlot"; + +type Props = { + // The Camera's picture number; the picture is fetched again whenever it changes. + pictureNumber: number; + shown: string; + // Where the newest picture is, or null when this page may not show it. + pictureUrl: string | null; +}; + +// A Camera's row. Where the page may show its picture, the line opens it: the Sandbox's Hub passes +// on any Sensor's picture, and outside the Sandbox only this Node's own Camera is reachable. +export function CameraLine({ pictureNumber, shown, pictureUrl }: Props) { + const [open, setOpen] = useState(false); + const label = `Camera: ${shown}`; + return ( + <> + + {pictureUrl ? ( + + ) : ( + label + )} + + {pictureUrl && open && ( +
+ {/* An , never inline markup: a script inside the SVG cannot run. */} + Simulated camera picture +
+ SIMULATED Stays on this Camera; it does not + cross the Mesh +
+
+ )} + + ); +} diff --git a/dashboard/src/ReadingsPanel.tsx b/dashboard/src/ReadingsPanel.tsx index c7632c0..dc181a3 100644 --- a/dashboard/src/ReadingsPanel.tsx +++ b/dashboard/src/ReadingsPanel.tsx @@ -1,5 +1,6 @@ import { Fragment } from "react"; +import { CameraLine } from "./CameraLine"; import { FloodRiskCard } from "./FloodRiskPanel"; import { NodeTags } from "./NodeTags"; import { Panel, type PanelControl } from "./Panel"; @@ -49,6 +50,8 @@ type Props = { trends: Record; onSelect: (id: string) => void; panel: PanelControl; + // True in the Sandbox, whose Hub passes on every Sensor's Camera picture. + inSandbox: boolean; }; export function ReadingsPanel({ @@ -63,6 +66,7 @@ export function ReadingsPanel({ trends, onSelect, panel, + inSandbox, }: Props) { const reducedMotion = usePrefersReducedMotion(); @@ -70,6 +74,15 @@ export function ReadingsPanel({ const firstNotCountedId = offRiver.find((node) => !node.registered && !node.isThisNode)?.id; const waveAfter = rungBeforeWave(rungs, waveKm); + // A picture never crosses the Mesh: the Sandbox's Hub has them all, anywhere else this page can + // reach only the Camera of the Node it is connected to. + const pictureUrlOf = (node: NodeOnMap) => + inSandbox + ? `/api/sandbox/sensors/${encodeURIComponent(node.id)}/picture` + : node.isThisNode + ? "/api/camera/picture" + : null; + const card = ( node: NodeOnMap, { hero, liveSensors }: { hero: boolean; liveSensors?: boolean }, @@ -112,12 +125,20 @@ export function ReadingsPanel({ .filter((measurement) => !(hero && measurement.kind === "water_level")) .map((measurement) => (
  • - - {kindLabel(measurement.kind)}: {measurement.shown} - + {measurement.kind === "camera" ? ( + + ) : ( + + {kindLabel(measurement.kind)}: {measurement.shown} + + )}
  • ))} {/* On a coast a simulated Sensor whose ground is above the water sends no level, not zero; diff --git a/dashboard/src/cameraPictureFlow.test.tsx b/dashboard/src/cameraPictureFlow.test.tsx new file mode 100644 index 0000000..3700b1d --- /dev/null +++ b/dashboard/src/cameraPictureFlow.test.tsx @@ -0,0 +1,94 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; + +import { App } from "./App"; +import { fakeNodeSnapshot, nepalNodeState } from "./fakeNodeState"; +import { STATE_POLL_MS } from "./useNodeState"; + +let pictureNumber: number; + +function stubTheNode({ sandbox }: { sandbox: boolean }) { + vi.stubGlobal("fetch", async (path: string) => { + if (path === "/api/state") { + return Response.json( + nepalNodeState({ + id: "porch", + name: "porch", + sandbox: sandbox ? { label: "SIMULATED" } : null, + nodes: [ + fakeNodeSnapshot("porch", { + measurements: { camera: { value: pictureNumber, unit: "pic" } }, + }), + fakeNodeSnapshot("hilltop", { + measurements: { camera: { value: 9, unit: "pic" } }, + }), + ], + }), + ); + } + if (path === "/api/sandbox/nodes") { + return Response.json({ + radio_range_m: 1000, + offered_kinds: ["camera"], + suggested_sites: [], + nodes: [], + }); + } + if (path === "/api/sandbox/regions") return Response.json({ current: "nepal", regions: [] }); + return new Response(null, { status: 404 }); + }); +} + +beforeEach(() => { + pictureNumber = 1; + vi.useFakeTimers({ shouldAdvanceTime: true }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function cameraCard(name: string) { + if (!document.body.firstChild) render(); + return await screen.findByRole("region", { name: `Node ${name}` }); +} + +test("in the Sandbox a Camera line opens its picture, marked SIMULATED, and it follows the picture number", async () => { + stubTheNode({ sandbox: true }); + const card = await cameraCard("hilltop"); + const line = await within(card).findByRole("button", { name: "Camera: Picture 9" }); + expect(within(card).queryByRole("img")).not.toBeInTheDocument(); + + fireEvent.click(line); + const picture = within(card).getByRole("img", { name: "Simulated camera picture" }); + expect(picture.tagName).toBe("IMG"); + expect(picture.getAttribute("src")).toBe("/api/sandbox/sensors/hilltop/picture?picture=9"); + const figure = picture.closest("figure") as HTMLElement; + expect(within(figure).getByText("SIMULATED")).toBeInTheDocument(); + expect( + within(figure).getByText(/Stays on this Camera; it does not cross the Mesh/), + ).toBeInTheDocument(); + + const porch = await cameraCard("porch"); + fireEvent.click(within(porch).getByRole("button", { name: "Camera: Picture 1" })); + expect(within(porch).getByRole("img").getAttribute("src")).toContain("picture=1"); + pictureNumber = 2; + await act(() => vi.advanceTimersByTimeAsync(STATE_POLL_MS)); + expect( + await within(porch).findByRole("button", { name: "Camera: Picture 2" }), + ).toBeInTheDocument(); + expect(within(porch).getByRole("img").getAttribute("src")).toContain("picture=2"); + + fireEvent.click(within(porch).getByRole("button", { name: "Camera: Picture 2" })); + expect(within(porch).queryByRole("img")).not.toBeInTheDocument(); +}); + +test("outside the Sandbox only the connected Node's own Camera can open", async () => { + stubTheNode({ sandbox: false }); + const other = await cameraCard("hilltop"); + expect(within(other).queryByRole("button", { name: /Camera/ })).not.toBeInTheDocument(); + expect(within(other).getByText("Camera: Picture 9")).toBeInTheDocument(); + + const own = await cameraCard("porch"); + fireEvent.click(await within(own).findByRole("button", { name: "Camera: Picture 1" })); + expect(within(own).getByRole("img").getAttribute("src")).toBe("/api/camera/picture?picture=1"); +}); From e556786aa68a0c4053a09a65e4df3f79707bdbb7 Mon Sep 17 00:00:00 2001 From: Fazorath Date: Mon, 2 Nov 2026 01:33:18 +0000 Subject: [PATCH 2/2] name the Camera label from kindLabel; rename the test helper (#117) --- dashboard/src/CameraLine.tsx | 3 ++- dashboard/src/cameraPictureFlow.test.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dashboard/src/CameraLine.tsx b/dashboard/src/CameraLine.tsx index 90901d0..b257552 100644 --- a/dashboard/src/CameraLine.tsx +++ b/dashboard/src/CameraLine.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; +import { kindLabel } from "./ReadingsPanel"; import { ReadingSlot } from "./ReadingSlot"; type Props = { @@ -14,7 +15,7 @@ type Props = { // on any Sensor's picture, and outside the Sandbox only this Node's own Camera is reachable. export function CameraLine({ pictureNumber, shown, pictureUrl }: Props) { const [open, setOpen] = useState(false); - const label = `Camera: ${shown}`; + const label = `${kindLabel("camera")}: ${shown}`; return ( <> diff --git a/dashboard/src/cameraPictureFlow.test.tsx b/dashboard/src/cameraPictureFlow.test.tsx index 3700b1d..aee3d6b 100644 --- a/dashboard/src/cameraPictureFlow.test.tsx +++ b/dashboard/src/cameraPictureFlow.test.tsx @@ -47,14 +47,14 @@ afterEach(() => { vi.useRealTimers(); }); -async function cameraCard(name: string) { +async function renderedCard(name: string) { if (!document.body.firstChild) render(); return await screen.findByRole("region", { name: `Node ${name}` }); } test("in the Sandbox a Camera line opens its picture, marked SIMULATED, and it follows the picture number", async () => { stubTheNode({ sandbox: true }); - const card = await cameraCard("hilltop"); + const card = await renderedCard("hilltop"); const line = await within(card).findByRole("button", { name: "Camera: Picture 9" }); expect(within(card).queryByRole("img")).not.toBeInTheDocument(); @@ -68,7 +68,7 @@ test("in the Sandbox a Camera line opens its picture, marked SIMULATED, and it f within(figure).getByText(/Stays on this Camera; it does not cross the Mesh/), ).toBeInTheDocument(); - const porch = await cameraCard("porch"); + const porch = await renderedCard("porch"); fireEvent.click(within(porch).getByRole("button", { name: "Camera: Picture 1" })); expect(within(porch).getByRole("img").getAttribute("src")).toContain("picture=1"); pictureNumber = 2; @@ -84,11 +84,11 @@ test("in the Sandbox a Camera line opens its picture, marked SIMULATED, and it f test("outside the Sandbox only the connected Node's own Camera can open", async () => { stubTheNode({ sandbox: false }); - const other = await cameraCard("hilltop"); + const other = await renderedCard("hilltop"); expect(within(other).queryByRole("button", { name: /Camera/ })).not.toBeInTheDocument(); expect(within(other).getByText("Camera: Picture 9")).toBeInTheDocument(); - const own = await cameraCard("porch"); + const own = await renderedCard("porch"); fireEvent.click(await within(own).findByRole("button", { name: "Camera: Picture 1" })); expect(within(own).getByRole("img").getAttribute("src")).toBe("/api/camera/picture?picture=1"); });