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>
78 lines
2.5 KiB
TypeScript
78 lines
2.5 KiB
TypeScript
"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>}
|
||
</>
|
||
);
|
||
}
|