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>
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
"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'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'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>
|
||
);
|
||
}
|