Simplify the dashboard: a picture, a name, a status

A card now leads with the frame, the camera's name, its status and Pop
out. The address, device line, stream summary, location, web page and
login links and the profile table all moved behind Details, whose closed
summary shows the device line. Scanning is one button, with timeout and
sweep behind Options summarised as "5s, sweep".

Anything needing attention stays outside any disclosure: setup required,
camera login, session ended, a stopped stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-20 06:41:46 -05:00
parent 213d1c05f3
commit efe49d7363
5 changed files with 125 additions and 60 deletions

View File

@ -1185,3 +1185,7 @@
{"id":"evt-jyahmdef91ar","type":"node.status_changed","subject":"iss-jz3431t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T11:39:22.694Z","parents":["evt-kzzwz3e5jc0h"],"hash":"3d87103db08734a822a9083195041bbca6305e086471b992149f1c11adfb8418"}
{"id":"evt-3sec7pkrge2y","type":"node.created","subject":"mea-4c17z3m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Interface simplification: 1 of 5 issues done","body":"Counted from vrek on 2026-09-20: the shared disclosure component and its rules are done (iss-jz3431t), which unblocks the four screens. Left: the dashboard, the camera page, the recordings page, and what a new install sees first.","status":"recorded","owner":null,"attrs":{"value":1,"applies_at":"2026-09-20"}},"at":"2026-09-20T11:39:23.964Z","parents":["evt-jyahmdef91ar"],"hash":"b5bff23790e8e894ef886795f96687eb1155dbaa70bccbf1b00c4d8a0cd35dab"}
{"id":"evt-k39g60g2v64h","type":"edge.added","subject":"mea-4c17z3m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-4c17z3m","to":"gol-6x4ctm2"},"at":"2026-09-20T11:39:23.975Z","parents":["evt-3sec7pkrge2y"],"hash":"ebbc6e8b6e4908ffd94cdc58e10ea239ed4c4bdc0c4f8dafef4b017250bc673e"}
{"id":"evt-xejefx0mvvv4","type":"node.created","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"A card leads with the picture, name, status and Pop out, with the address, device line, streams, location, links and profile table behind Details; scanning is one button with its options folded away and summarised; anything needing attention is never hidden","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T11:41:37.164Z","parents":["evt-k39g60g2v64h"],"hash":"d6db65efbf637e7fee67e7339d9eba9953795de8b7d793068a5a23e4dc1edaf9"}
{"id":"evt-stnszeba14yw","type":"edge.added","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-c6d8r7n","to":"iss-t9twsxg"},"at":"2026-09-20T11:41:37.165Z","parents":["evt-xejefx0mvvv4"],"hash":"b16063efec294f51fc6e3b33a272edf8449798ac0335bbad223c055afb60e716"}
{"id":"evt-t0r3gbcnybbv","type":"verification.recorded","subject":"ver-c6d8r7n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"camera-card.tsx and camera-scanner.tsx now use the shared Disclosure (dec-0kchta3). Tests in camera-card.test.tsx (\"showing less by default\": what sits outside the details, what sits inside, and that a camera needing setup is not inside any details) and camera-scanner.test.tsx (options closed, summarised as \"5s, sweep\", both controls inside, the button outside). Closed is not hidden, so the existing assertions about that content still hold, which is the point. Full suite 967 tests pass, 99.66% lines; tsc, eslint and next build clean. Not yet seen in a browser."},"at":"2026-09-20T11:41:37.166Z","parents":["evt-stnszeba14yw"],"hash":"863c4152bfd102b11c1dc638ce574d96263239839122235c558afc5b0cf0d179"}
{"id":"evt-7fjdxzernfba","type":"node.status_changed","subject":"iss-t9twsxg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-20T11:41:38.231Z","parents":["evt-t0r3gbcnybbv"],"hash":"f22b614fb2b007d7e7391e3ba7d3d66318ad3f8a551d23d388c6cad2a2b0aef9"}

View File

@ -55,6 +55,45 @@ describe("CameraCard", () => {
);
});
describe("showing less by default (iss-t9twsxg)", () => {
/** What a closed <details> holds: present for search and screen readers, not on show. */
const detail = () => document.querySelector("details")!;
it("leads with the picture, the name, the status and Pop out", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
await screen.findByText("Hikvision DS-2CD · fw V5.7");
const outside = detail().parentElement!.textContent!.replace(detail().textContent!, "");
expect(outside).toContain("Porch");
expect(outside).toContain("Pop out");
expect(outside).not.toContain("192.168.1.10:8080");
});
it("keeps the address, streams, location, links and profiles for when they're asked for", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
await screen.findByText("Hikvision DS-2CD · fw V5.7");
expect(detail().open).toBe(false);
const inside = detail().textContent!;
for (const text of ["192.168.1.10:8080", "Front door", "Web page", "Change login", "1920×1080"]) {
expect(inside).toContain(text);
}
// Closed still says something useful.
expect(screen.getByText("Hikvision DS-2CD · fw V5.7")).toBeTruthy();
});
it("never hides a camera that needs attention", async () => {
stubFetch({
[`GET ${base}/info`]: () => json({ error: "not activated", code: "inactive" }, 409),
});
renderWithQuery(<CameraCard cam={cam} intervalMs={1000} />);
const panel = await screen.findByText("Setup required: camera not activated");
expect(panel.closest("details")).toBeNull();
});
});
describe("pop-out (iss-mt5zv64)", () => {
const settle = () => new Promise((r) => setTimeout(r, 30));

View File

@ -25,6 +25,7 @@ import {
type CameraProblem,
} from "./camera-queries";
import { cameraStatus, StatusBadge } from "./camera-status";
import Disclosure from "./disclosure";
import { deviceLine, streamSummary } from "./camera-summary";
import FrameImage from "./frame-image";
import { openPopout, popoutUrl, popoutWindowName, watchPopout } from "./popout";
@ -286,21 +287,27 @@ export default function CameraCard({
poppedOut={poppedOut}
/>
<div>
<div className="flex items-center justify-between gap-2">
<Link href={`/cameras/${cam.id}`} className="font-medium hover:underline">
{cam.name ?? info.data?.model ?? "Unnamed camera"}
</Link>
<div className="flex items-center justify-between gap-2">
<Link href={`/cameras/${cam.id}`} className="font-medium hover:underline">
{cam.name ?? info.data?.model ?? "Unnamed camera"}
</Link>
<span className="flex items-center gap-3">
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
<StatusBadge
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
/>
</div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm">
</span>
</div>
{/* Everything below is detail: useful when asked for, noise on a wall of cameras. */}
<Disclosure id={`camera-details-${cam.id}`} label="Details" summary={device ?? undefined}>
<div className="flex flex-col gap-1 text-sm text-zinc-600 dark:text-zinc-400">
<span className="font-mono">
{cam.host}:{cam.port}
</span>
<span className="flex gap-3 text-xs">
<PopoutLink cam={cam} intervalMs={intervalMs} aspect={aspect} />
{summary && <span>{summary}</span>}
{cam.location && <span>{cam.location}</span>}
<span className="flex gap-3 pt-1 text-xs">
<WebUiLink cam={cam}>Web page</WebUiLink>
{!showLogin && !showSetup && (
<button
@ -312,13 +319,30 @@ export default function CameraCard({
</button>
)}
</span>
{info.data && info.data.profiles.length > 0 && (
<table className="mt-1 text-xs">
<thead className="text-left text-zinc-500">
<tr>
<th className="font-normal">Profile</th>
<th className="font-normal">Codec</th>
<th className="font-normal">Resolution</th>
<th className="font-normal">FPS</th>
</tr>
</thead>
<tbody className="font-mono">
{info.data.profiles.map((p) => (
<tr key={p.token}>
<td>{p.name ?? p.token}</td>
<td>{p.encoding ?? "?"}</td>
<td>{p.width && p.height ? `${p.width}×${p.height}` : "?"}</td>
<td>{p.fps ?? "?"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{device && <div className="text-sm text-zinc-600 dark:text-zinc-400">{device}</div>}
{summary && <div className="text-sm text-zinc-600 dark:text-zinc-400">{summary}</div>}
{cam.location && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">{cam.location}</div>
)}
</div>
</Disclosure>
{showSetup && <SetupPanel cam={cam} onCheckAgain={retry} />}
@ -357,28 +381,6 @@ export default function CameraCard({
</div>
)}
{info.data && info.data.profiles.length > 0 && (
<table className="text-xs">
<thead className="text-left text-zinc-500">
<tr>
<th className="font-normal">Profile</th>
<th className="font-normal">Codec</th>
<th className="font-normal">Resolution</th>
<th className="font-normal">FPS</th>
</tr>
</thead>
<tbody className="font-mono">
{info.data.profiles.map((p) => (
<tr key={p.token}>
<td>{p.name ?? p.token}</td>
<td>{p.encoding ?? "?"}</td>
<td>{p.width && p.height ? `${p.width}×${p.height}` : "?"}</td>
<td>{p.fps ?? "?"}</td>
</tr>
))}
</tbody>
</table>
)}
</li>
);
}

View File

@ -35,6 +35,20 @@ describe("CameraScanner", () => {
expect(await screen.findByText("Found 1 camera in 1s.")).toBeTruthy();
});
it("is one button, with the options folded away and summarised", () => {
stubFetch({ "POST /api/discover": () => new Promise(() => {}) });
renderWithQuery(<CameraScanner />);
const options = document.querySelector("details")!;
expect(options.open).toBe(false);
expect(options.textContent).toContain("Options");
expect(options.textContent).toContain("5s, sweep"); // the defaults, said plainly
expect(screen.getByLabelText(/Timeout/).closest("details")).toBe(options);
expect(screen.getByLabelText("Unicast sweep of local subnet").closest("details")).toBe(options);
// The button itself is not inside the options.
expect(screen.getByRole("button", { name: /Scan network/ }).closest("details")).toBeNull();
});
it("says what it is doing while it runs, and while the list updates", async () => {
let finish!: (res: Response) => void;
stubFetch({ "POST /api/discover": () => new Promise((r) => (finish = r)) });

View File

@ -4,6 +4,7 @@ import { CircleAlert, LoaderCircle, Radar } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useTransition } from "react";
import { useScanNetwork, type ScanReport } from "./camera-queries";
import Disclosure from "./disclosure";
/**
* Runs a network scan. The scan writes the server-side registry, so afterwards the
@ -76,28 +77,33 @@ export default function CameraScanner() {
)}
{scan.isPending ? `Scanning… ${elapsed}s of ${timeoutSecs}s` : refreshing ? "Updating the list…" : "Scan network"}
</button>
<label className="flex items-center gap-2 text-sm">
Timeout
<input
type="number"
min={1}
max={30}
value={timeoutSecs}
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
disabled={busy}
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
s
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={unicastSweep}
onChange={(e) => setUnicastSweep(e.target.checked)}
disabled={busy}
/>
Unicast sweep of local subnet
</label>
{/* The defaults suit nearly every scan, so the knobs stay out of the way. */}
<Disclosure id="scan-options" label="Options" summary={`${timeoutSecs}s${unicastSweep ? ", sweep" : ""}`}>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-sm">
Timeout
<input
type="number"
min={1}
max={30}
value={timeoutSecs}
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
disabled={busy}
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
s
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={unicastSweep}
onChange={(e) => setUnicastSweep(e.target.checked)}
disabled={busy}
/>
Unicast sweep of local subnet
</label>
</div>
</Disclosure>
{scan.isPending && (
<p role="status" className="w-full text-sm text-zinc-600 dark:text-zinc-400">