cameras/src/app/camera-card.tsx
Michael Mainguy 0ec434ca26 Add read-only camera settings page and dashboard stream summary
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) <noreply@anthropic.com>
2026-09-19 13:00:37 -05:00

340 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
CircleAlert,
ExternalLink,
KeyRound,
LoaderCircle,
Lock,
LogIn,
RefreshCw,
Trash2,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import {
useCameraInfo,
useCameraSnapshot,
useClearCredentials,
useCredentialStatus,
useResetCamera,
useSaveCredentials,
type CameraProblem,
} from "./camera-queries";
import { cameraStatus, StatusBadge } from "./camera-status";
import { deviceLine, streamSummary } from "./camera-summary";
const DEFAULT_USERNAME = "camera";
/** The camera's own web interface; it can't be framed (X-Frame-Options: SAMEORIGIN). */
function webUiUrl(cam: CameraSummary) {
return `http://${cam.host}${cam.port === 80 ? "" : `:${cam.port}`}/`;
}
function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.ReactNode }) {
return (
<a href={webUiUrl(cam)} target="_blank" rel="noopener noreferrer" className="underline">
{children}
<ExternalLink aria-hidden className="ml-0.5 inline size-3 align-[-0.1em]" />
</a>
);
}
function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) {
return (
<div className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="flex items-center gap-1.5 font-medium">
<TriangleAlert aria-hidden className="size-4 shrink-0 text-amber-600 dark:text-amber-400" />
Setup required: camera not activated
</div>
<ol className="list-decimal space-y-1 pl-5 text-zinc-700 dark:text-zinc-300">
<li>
<WebUiLink cam={cam}>Open the camera&apos;s web page</WebUiLink> and activate it by
choosing an admin password. Save it in your password manager; this app never stores
it.
</li>
<li>
Enable ONVIF (usually Configuration → Network → Advanced Settings → Integration
Protocol).
</li>
<li>
On the same page, add an ONVIF user named <code>{DEFAULT_USERNAME}</code> with the{" "}
<strong>Operator</strong> level.
</li>
<li>Come back here and click Check again, then enter that user&apos;s login.</li>
</ol>
<div>
<button
onClick={onCheckAgain}
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white dark:bg-white dark:text-black"
>
<RefreshCw aria-hidden className="size-4" />
Check again
</button>
</div>
</div>
);
}
/** Shows the latest frame; polling itself lives in useCameraSnapshot. */
function Snapshot({ cam, frame, paused }: { cam: CameraSummary; frame?: Blob; paused: boolean }) {
const imgRef = useRef<HTMLImageElement>(null);
// Each frame gets one object URL, created and revoked in the same effect so it is freed
// exactly once, including under Strict Mode's double rendering.
useEffect(() => {
const img = imgRef.current;
if (!frame || !img) return;
const url = URL.createObjectURL(frame);
img.src = url;
return () => URL.revokeObjectURL(url);
}, [frame]);
return (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
{frame && (
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
<img
ref={imgRef}
alt={cam.name ?? cam.host}
className={`h-full w-full object-contain ${paused ? "opacity-40" : ""}`}
/>
)}
{!frame && !paused && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
</div>
)}
</div>
);
}
function CredentialsForm({
cam,
reason,
onSaved,
onCancel,
}: {
cam: CameraSummary;
reason?: string;
/** Called after a save or clear; the camera's queries have already been reset. */
onSaved: () => void;
onCancel?: () => void;
}) {
const status = useCredentialStatus(cam.id);
const save = useSaveCredentials(cam.id);
const clear = useClearCredentials(cam.id);
// null until the user types, so the saved username (never the password) can prefill it.
const [typedUsername, setTypedUsername] = useState<string | null>(null);
const [password, setPassword] = useState("");
const username = typedUsername ?? status.data?.username ?? DEFAULT_USERNAME;
function submit(e: React.FormEvent) {
e.preventDefault();
save.mutate(
{ username, password },
{
onSuccess: () => {
setPassword("");
onSaved();
},
},
);
}
const error = save.error ?? clear.error;
const busy = save.isPending || clear.isPending;
return (
<form onSubmit={submit} className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="flex items-center gap-1.5 font-medium">
<KeyRound aria-hidden className="size-4 shrink-0" />
Camera login
</div>
{reason && <div className="text-xs text-zinc-500">{reason}</div>}
<div className="text-xs text-zinc-500">
Use the ONVIF user you created on the camera (
<WebUiLink cam={cam}>camera web page</WebUiLink>).
</div>
<input
value={username}
onChange={(e) => setTypedUsername(e.target.value)}
placeholder="Username"
autoComplete="off"
required
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
autoComplete="new-password"
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
{error && (
<div className="flex items-center gap-1.5 text-red-600">
<CircleAlert aria-hidden className="size-4 shrink-0" />
{error === save.error && error.kind === "auth" ? "Camera rejected that login." : error.message}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={busy}
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{save.isPending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock aria-hidden className="size-4" />
)}
{save.isPending ? "Checking…" : "Save"}
</button>
{status.data?.source === "stored" && (
<button
type="button"
disabled={busy}
onClick={() => clear.mutate(undefined, { onSuccess: onSaved })}
className="inline-flex items-center gap-1.5 rounded px-3 py-1 underline disabled:opacity-50"
>
<Trash2 aria-hidden className="size-4" />
Forget saved login
</button>
)}
{onCancel && (
<button type="button" onClick={onCancel} className="rounded px-3 py-1 underline">
Cancel
</button>
)}
</div>
</form>
);
}
export default function CameraCard({
cam,
intervalMs,
}: {
cam: CameraSummary;
intervalMs: number;
}) {
const info = useCameraInfo(cam.id);
const snapshot = useCameraSnapshot(cam.id, intervalMs, info.isSuccess);
const resetCamera = useResetCamera(cam.id);
const [editingLogin, setEditingLogin] = useState(false);
const problem: CameraProblem | null = info.error ?? snapshot.error;
function retry() {
setEditingLogin(false);
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);
return (
<li className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<Snapshot cam={cam} frame={snapshot.data} paused={problem !== null || !info.isSuccess} />
<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>
<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">
{cam.host}:{cam.port}
</span>
<span className="flex gap-3 text-xs">
<WebUiLink cam={cam}>Web page</WebUiLink>
{!showLogin && !showSetup && (
<button
onClick={() => setEditingLogin(true)}
className="inline-flex items-center gap-1 underline"
>
<KeyRound aria-hidden className="size-3.5" />
Change login
</button>
)}
</span>
</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>
{showSetup && <SetupPanel cam={cam} onCheckAgain={retry} />}
{showLogin && (
<CredentialsForm
cam={cam}
reason={problem?.kind === "auth" ? problem.message : undefined}
onSaved={() => setEditingLogin(false)}
onCancel={problem?.kind === "auth" ? undefined : () => setEditingLogin(false)}
/>
)}
{problem?.kind === "signed-out" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span className="inline-flex items-center gap-1.5">
<CircleAlert aria-hidden className="size-4 shrink-0" />
Your session has ended.
</span>
<a href="/login" className="inline-flex items-center gap-1 underline">
<LogIn aria-hidden className="size-4" />
Sign in again
</a>
</div>
)}
{problem?.kind === "error" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span className="inline-flex items-center gap-1.5">
<CircleAlert aria-hidden className="size-4 shrink-0" />
Stopped: {problem.message}
</span>
<button onClick={retry} className="inline-flex items-center gap-1 underline">
<RefreshCw aria-hidden className="size-4" />
Retry
</button>
</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>
);
}