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
12 changes: 12 additions & 0 deletions dashboard/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -2101,6 +2101,18 @@ section p.flood-prone-caption {
margin-top: 0.75rem;
}

.sandbox-stop {
margin-top: 0.75rem;
}

.sandbox-stopped {
display: grid;
place-content: center;
min-height: 100vh;
padding: 2rem;
text-align: center;
}

.sandbox-add[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent);
Expand Down
15 changes: 15 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export function App() {
removeSensor,
setSensorKinds,
switchRegion,
stopSandbox,
} = useSandboxNodes(state?.sandbox != null, refresh);
const [sandboxStopped, setSandboxStopped] = useState(false);
const [sandboxPanelOpen, setSandboxPanelOpen] = useState(true);
const [placingSensor, setPlacingSensor] = useState(false);
const eventClock = useEventClock(!unreachable);
Expand Down Expand Up @@ -273,6 +275,11 @@ export function App() {
sandbox={sandbox}
regions={regions}
onSwitchRegion={switchRegion}
onStop={
state?.sandbox?.can_stop
? () => void stopSandbox().then((stopped) => setSandboxStopped(stopped))
: null
}
problem={problem}
onSetRadioRange={setRadioRange}
placing={placingSensor}
Expand Down Expand Up @@ -341,6 +348,14 @@ export function App() {
}
: null;
const selectedNode = nodes.find((node) => node.id === selectedId);
if (sandboxStopped) {
return (
<main className="sandbox-stopped">
<h1>Storm Shield Sandbox has stopped</h1>
<p>It is safe to close this tab.</p>
</main>
);
}
return (
<RegionContext.Provider value={region}>
<main className={phone ? "phone" : undefined}>
Expand Down
8 changes: 8 additions & 0 deletions dashboard/src/SandboxPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type Props = {
sandbox: SandboxNodes;
regions: SandboxRegions | null;
onSwitchRegion: (name: string) => Promise<void>;
// Present only in the app, the one Sandbox a Stop button can end.
onStop: (() => void) | null;
problem: string | null;
onSetRadioRange: (metres: number) => Promise<void>;
// "Add Sensor" arms the map: the next tap places a Sensor. Pressing it again disarms.
Expand Down Expand Up @@ -42,6 +44,7 @@ export function SandboxPanel({
sandbox,
regions,
onSwitchRegion,
onStop,
problem,
onSetRadioRange,
placing,
Expand Down Expand Up @@ -94,6 +97,11 @@ export function SandboxPanel({
>
Add Sensor
</button>
{onStop && (
<button type="button" className="sandbox-stop" onClick={onStop}>
Stop Sandbox
</button>
)}
{problem && <p role="alert">{problem}</p>}
<ReplayControls eventClock={eventClock} />
<ul aria-label="Sandbox Nodes" className="sandbox-nodes">
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/cameraPictureFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function stubTheNode({ sandbox }: { sandbox: boolean }) {
nepalNodeState({
id: "porch",
name: "porch",
sandbox: sandbox ? { label: "SIMULATED" } : null,
sandbox: sandbox ? { label: "SIMULATED", can_stop: false } : null,
nodes: [
fakeNodeSnapshot("porch", {
measurements: { camera: { value: pictureNumber, unit: "pic" } },
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/nodeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ export type NodeState = {
status: NodeStatus;
registration: Registration;
// Present only when the Node is the Sandbox's Hub; nothing in the Sandbox is a real reading.
sandbox: { label: "SIMULATED" } | null;
// `can_stop` is true only in the double-click app, whose Panel offers Stop.
sandbox: { label: "SIMULATED"; can_stop: boolean } | null;
region: Region;
hazard: Hazard;
// The Node's own verdict that real Sensors are reporting; the dashboard never re-derives it.
Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/replayControlsFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const MADE_UP_PHASES = [{ id: "only", label: "The only phase", event_t: REPLAY_S

let replay: object;
let events: object | null;
let sandbox: { label: "SIMULATED" } | null;
let sandbox: { label: "SIMULATED"; can_stop: false } | null;
let posted: { path: string; body: unknown }[];
let fetched: string[];

Expand Down Expand Up @@ -227,7 +227,7 @@ test("a running Event is labelled REPLAY beside its clock", async () => {

test("the Sandbox Panel holds the same Replay controls", async () => {
events = EVENTS;
sandbox = { label: "SIMULATED" };
sandbox = { label: "SIMULATED", can_stop: false };
render(<App />);
const panel = await screen.findByRole("region", { name: "Sandbox" });
const picker = await within(panel).findByRole("combobox", { name: "Event" });
Expand Down
44 changes: 43 additions & 1 deletion dashboard/src/sandboxPanelFlow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ function stubTheHub(options: {
heardNodes?: boolean;
// What the Hub answers when asked to switch Region: a message refuses with it.
regionRefusal?: string;
// The app can be stopped from its Panel; the Sandbox from a checkout cannot.
canStop?: boolean;
// What the Hub answers to Stop: a message refuses with it.
stopRefusal?: string;
}) {
posted = [];
sent = [];
Expand Down Expand Up @@ -107,6 +111,13 @@ function stubTheHub(options: {
current = answer;
return Response.json(current);
}
if (path === "/api/sandbox/stop" && init?.method === "POST") {
posted.push({ path, body: undefined });
if (options.stopRefusal) {
return Response.json({ detail: options.stopRefusal }, { status: 403 });
}
return Response.json({ stopping: true });
}
if (path === "/api/sandbox/regions") return Response.json(REGIONS);
if (path === "/api/sandbox/region" && init?.method === "POST") {
const body = JSON.parse(init.body as string);
Expand All @@ -121,7 +132,9 @@ function stubTheHub(options: {
nepalNodeState({
id: "hub",
name: "hub",
sandbox: options.sandbox ? { label: "SIMULATED" } : null,
sandbox: options.sandbox
? { label: "SIMULATED", can_stop: options.canStop ?? false }
: null,
// Where the readings say, which is not quite where the Node is.
nodes: options.heardNodes
? WIDE.nodes.map((each) =>
Expand Down Expand Up @@ -526,3 +539,32 @@ test("a refused kind shows the Hub's sentence", async () => {
"Could not change the Sensor's kinds: the Hub said no",
);
});

test("the app's Panel has a Stop button, and after it the page says it is safe to close", async () => {
stubTheHub({ sandbox: true, canStop: true });
const panel = await sandboxPanel();
fireEvent.click(await within(panel).findByRole("button", { name: "Stop Sandbox" }));
expect(await screen.findByText("It is safe to close this tab.")).toBeInTheDocument();
expect(posted).toEqual([{ path: "/api/sandbox/stop", body: undefined }]);
});

test("a Stop the Hub refuses says so and leaves the dashboard up", async () => {
stubTheHub({
sandbox: true,
canStop: true,
stopRefusal: "only this Sandbox's own page can stop it",
});
const panel = await sandboxPanel();
fireEvent.click(await within(panel).findByRole("button", { name: "Stop Sandbox" }));
expect(await within(panel).findByRole("alert")).toHaveTextContent(
"Could not stop the Sandbox: only this Sandbox's own page can stop it",
);
expect(screen.queryByText("It is safe to close this tab.")).not.toBeInTheDocument();
});

test("outside the app the Panel has no Stop button", async () => {
stubTheHub({ sandbox: true });
const panel = await sandboxPanel();
await within(panel).findAllByRole("listitem");
expect(within(panel).queryByRole("button", { name: "Stop Sandbox" })).not.toBeInTheDocument();
});
15 changes: 15 additions & 0 deletions dashboard/src/useSandboxNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export type SandboxFeed = {
// Declares `kinds` on a Sensor that stays where it is.
setSensorKinds: (id: string, position: [number, number], kinds: string[]) => Promise<void>;
switchRegion: (name: string) => Promise<void>;
// Ends the Sandbox app; true once it has said it is stopping, false (with `problem` set) if not.
stopSandbox: () => Promise<boolean>;
};

async function ask<Answer = SandboxNodes>(path: string, init?: RequestInit): Promise<Answer> {
Expand Down Expand Up @@ -174,11 +176,24 @@ export function useSandboxNodes(inSandbox: boolean, onRegionSwitched: () => void
[onRegionSwitched],
);

const stopSandbox = useCallback(async () => {
setProblem(null);
try {
await ask("/api/sandbox/stop", sendJson("POST"));
return true;
} catch (error) {
console.error(error);
setProblem(`Could not stop the Sandbox: ${(error as Error).message}`);
return false;
}
}, []);

return {
sandbox: inSandbox ? sandbox : null,
regions: inSandbox ? regions : null,
problem,
switchRegion,
stopSandbox,
setRadioRange,
addSensor,
moveSensor,
Expand Down
1 change: 1 addition & 0 deletions node/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m
- **A Sensor sends an Event's extra `tracks` (made-up Events only) only when `NODE_MEASURES` names the kind**; the default names the flood four, so only the Sandbox (`SANDBOX_MEASURES`) or a Node told so sends `air_temperature`, `humidity`, `wind_speed`.
- **A Camera is a Sensor whose `NODE_MEASURES` names `camera`**: each share bumps its picture number and redraws its one in-memory SVG (`camera.py`, fed only a water depth, so nothing about where it is can leak). The Sandbox Hub reads Sensors' pictures in-process at `/api/sandbox/sensors/<id>/picture`; `MeshedNodes.record_readings()` hears what is on the Mesh.
- **App file locations live in `app_files.py`** (dashboard, Regions, built and downloaded Cache Packs); a test that needs other packs patches `sandbox.BUILT_PACKS_DIR` / `DOWNLOADED_PACKS_DIR`. `storm-shield-sandbox-app` is the no-argument app start: port 8000 or a free one, log and failure page in the temp folder; uvicorn exits with `SystemExit` when it cannot bind, so the app catches that too.
- **CI's Node job has no `pnpm` and no built dashboard**, so a test that calls `sandbox.serve()` patches `build_dashboard_if_missing`, and collects what the serving thread raises: a thread that died on startup looks the same as one that ended on purpose.

Networking and Pi lessons (firewalls, TCP peers) are in `pi/AGENTS.md`.
20 changes: 19 additions & 1 deletion node/src/storm_shield/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
SandboxRegions,
SensorChange,
UnknownSensor,
is_from_this_machine,
)
from storm_shield.settings import Role, Settings
from storm_shield.shelter_status import (
Expand Down Expand Up @@ -111,6 +112,8 @@ class SandboxState(BaseModel):
"""Present in `/api/state` only in the Sandbox, where nothing is a real reading."""

label: Literal["SIMULATED"] = "SIMULATED"
# Whether the Panel may offer a Stop button: only the double-click app can be stopped.
can_stop: bool = False


class OfficialGaugeState(BaseModel):
Expand Down Expand Up @@ -781,7 +784,7 @@ def node_state() -> NodeState:
role=settings.role,
status=internet.status,
registration=registration(),
sandbox=SandboxState() if sandbox else None,
sandbox=SandboxState(can_stop=sandbox.can_stop()) if sandbox else None,
region=region.summary(),
hazard=region.hazard,
live=full_snapshot.is_live(registered_ids),
Expand Down Expand Up @@ -1020,6 +1023,21 @@ def jump_replay(jump: ReplayJump) -> ReplayClockMessage:
event, "paused" if keeps_paused else "playing", event.event_t_at(phase.at)
)

if sandbox is not None and sandbox.can_stop():

@app.post("/api/sandbox/stop")
def stop_sandbox(request: Request) -> dict[str, bool]:
if not is_from_this_machine(
request.client.host if request.client else None,
request.headers.get("host"),
request.headers.get("origin"),
):
raise HTTPException(
status_code=403, detail="only this Sandbox's own page can stop it"
)
sandbox.stop()
return {"stopping": True}

if sandbox is not None:

@app.get("/api/sandbox/nodes")
Expand Down
Loading
Loading