Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dashboard/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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`.
34 changes: 34 additions & 0 deletions dashboard/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export function App() {
rises={rises}
trends={trends}
panel={panel("readings")}
inSandbox={sandbox !== null}
/>
);
const sandboxPanel = sandbox && (
Expand Down
51 changes: 51 additions & 0 deletions dashboard/src/CameraLine.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useState } from "react";

import { kindLabel } from "./ReadingsPanel";
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 = `${kindLabel("camera")}: ${shown}`;
return (
<>
<ReadingSlot value={pictureNumber}>
{pictureUrl ? (
<button
type="button"
className="camera-toggle"
aria-expanded={open}
onClick={(event) => {
// The card behind the line selects its Node on a click; opening a picture should not.
event.stopPropagation();
setOpen((was) => !was);
}}
>
{label}
</button>
) : (
label
)}
</ReadingSlot>
{pictureUrl && open && (
<figure className="camera-picture">
{/* An <img>, never inline markup: a script inside the SVG cannot run. */}
<img src={`${pictureUrl}?picture=${pictureNumber}`} alt="Simulated camera picture" />
<figcaption>
<strong className="tag simulated">SIMULATED</strong> Stays on this Camera; it does not
cross the Mesh
</figcaption>
</figure>
)}
</>
);
}
33 changes: 27 additions & 6 deletions dashboard/src/ReadingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,6 +50,8 @@ type Props = {
trends: Record<string, Trend>;
onSelect: (id: string) => void;
panel: PanelControl;
// True in the Sandbox, whose Hub passes on every Sensor's Camera picture.
inSandbox: boolean;
};

export function ReadingsPanel({
Expand All @@ -63,13 +66,23 @@ export function ReadingsPanel({
trends,
onSelect,
panel,
inSandbox,
}: Props) {
const reducedMotion = usePrefersReducedMotion();

const { pinned, rungs, offRiver } = readingsLadder(nodes, hubId);
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 },
Expand Down Expand Up @@ -112,12 +125,20 @@ export function ReadingsPanel({
.filter((measurement) => !(hero && measurement.kind === "water_level"))
.map((measurement) => (
<li key={measurement.kind}>
<ReadingSlot
value={measurement.value}
trend={trends[historyKey(node.id, measurement.kind)]}
>
{kindLabel(measurement.kind)}: {measurement.shown}
</ReadingSlot>
{measurement.kind === "camera" ? (
<CameraLine
pictureNumber={measurement.value}
shown={measurement.shown}
pictureUrl={pictureUrlOf(node)}
/>
) : (
<ReadingSlot
value={measurement.value}
trend={trends[historyKey(node.id, measurement.kind)]}
>
{kindLabel(measurement.kind)}: {measurement.shown}
</ReadingSlot>
)}
</li>
))}
{/* On a coast a simulated Sensor whose ground is above the water sends no level, not zero;
Expand Down
94 changes: 94 additions & 0 deletions dashboard/src/cameraPictureFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -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 renderedCard(name: string) {
if (!document.body.firstChild) render(<App />);
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 renderedCard("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 renderedCard("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 renderedCard("hilltop");
expect(within(other).queryByRole("button", { name: /Camera/ })).not.toBeInTheDocument();
expect(within(other).getByText("Camera: Picture 9")).toBeInTheDocument();

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");
});
Loading