cameras/src/app/camera-scanner.tsx
Michael Mainguy f39d16a8c0 Add ONVIF camera app with a server-rendered camera dashboard
Discovers ONVIF cameras (Hikvision/Annke) over WS-Discovery, keeps a
server-side registry and an encrypted credential store, and serves
info, snapshot and credentials routes for each camera.

The home page is now a Server Component that lists known cameras from
the registry on load, without a scan (vrek iss-a0hz0py). The snapshot
refresh rate lives in the URL (?refresh=), and a scan refreshes the
server-rendered list.

Route input is validated with zod (iss-rjqy3hy), and /api/discover no
longer returns raw error messages (iss-dbwgww8). Adds
@tanstack/react-query and zod as dependencies, with a QueryClient
provider in the root layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 08:40:32 -05:00

78 lines
2.5 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 { useRouter } from "next/navigation";
import { useState } from "react";
/**
* Runs a network scan. The scan writes the server-side registry, so afterwards the
* page is refreshed and the server re-renders the camera list.
*/
export default function CameraScanner() {
const router = useRouter();
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [timeout, setTimeoutSecs] = useState(5);
const [unicastSweep, setUnicastSweep] = useState(true);
async function scan() {
setScanning(true);
setError(null);
setResult(null);
try {
const res = await fetch("/api/discover", {
method: "POST",
headers: { "Content-Type": "application/json" },
// The server rejects timeouts outside 1–30 s; keep a half-typed value in range.
body: JSON.stringify({
timeout: Math.min(Math.max(Math.round(timeout) || 5, 1), 30) * 1000,
unicastSweep,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
const found = data.cameras.length;
setResult(`Scan found ${found} camera${found === 1 ? "" : "s"}`);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setScanning(false);
}
}
return (
<>
<button
onClick={scan}
disabled={scanning}
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{scanning ? "Scanning…" : "Scan network"}
</button>
<label className="flex items-center gap-2 text-sm">
Timeout
<input
type="number"
min={1}
max={30}
value={timeout}
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
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)}
/>
Unicast sweep of local subnet
</label>
{result && <p className="w-full text-sm text-zinc-600 dark:text-zinc-400">{result}</p>}
{error && <p className="w-full text-sm text-red-600">Error: {error}</p>}
</>
);
}