feat(setup): generate the camera's Alarm Server settings to paste

When a camera has Alarm Server push enabled, the setup form now shows the
camera's Alarm Settings (Destination IP / URL / Protocol / Port) ready to copy,
so the operator never hunts the deviceId or memorises the endpoint.

CRUCIAL: host/port come from the BACKEND address on the camera's subnet
(backendIpForDevice + the server's listen port — the same probe the push-IP
picker uses), NOT window.location.origin (the SPA's dev/proxy origin, which
would wrongly say localhost:5173). Verified live: matches the on-camera config
field-for-field (10.0.10.203 / …/event / HTTP / 3000). Shows a "save first"
(needs a deviceId) then "test first" (needs the resolved backend IP) hint.
i18n keys added to sq + en (parity enforced).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-26 16:47:02 +02:00
parent f0fd15bb88
commit 40de8a7467
3 changed files with 98 additions and 3 deletions
+71 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
assignDevice, assignDevice,
@@ -376,6 +376,7 @@ function DeviceForm({
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null); const [testError, setTestError] = useState<string | null>(null);
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below. // ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null); const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
const [anprTesting, setAnprTesting] = useState(false); const [anprTesting, setAnprTesting] = useState(false);
const [anprError, setAnprError] = useState<string | null>(null); const [anprError, setAnprError] = useState<string | null>(null);
@@ -387,22 +388,31 @@ function DeviceForm({
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null); const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>(""); const [backendIp, setBackendIp] = useState<string>("");
// The server's listen port (e.g. 3000) the device must POST to — NOT the page's
// port (the SPA may be served by Vite on :5173 in dev, or behind a proxy on :80).
// Comes from the same /api/setup/backend-ips probe as the IPs.
const [backendPort, setBackendPort] = useState<number | null>(null);
const testedHost = tested ? String(mergedScalarConfig().host ?? "") : ""; const testedHost = tested ? String(mergedScalarConfig().host ?? "") : "";
useEffect(() => { useEffect(() => {
if (!testedHost || !pushesToBackend) { if (!testedHost || !pushesToBackend) {
setBackendIps(null); setBackendIps(null);
setBackendPort(null);
return; return;
} }
let live = true; let live = true;
fetchBackendIps(testedHost) fetchBackendIps(testedHost)
.then(({ candidates }) => { .then(({ candidates, port }) => {
if (!live) return; if (!live) return;
setBackendIps(candidates); setBackendIps(candidates);
setBackendPort(port);
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || ""); setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
}) })
.catch(() => { .catch(() => {
if (live) setBackendIps(null); if (live) {
setBackendIps(null);
setBackendPort(null);
}
}); });
return () => { return () => {
live = false; live = false;
@@ -730,6 +740,64 @@ function DeviceForm({
</label> </label>
)} )}
{/* CAMERA + Alarm Server push ON: show the camera's Alarm Server settings,
ready to copy, so the operator never has to find the deviceId or memorise the
endpoint. The CAMERA reaches us over the device VLAN, NOT via the browser's
origin — so host/port are the BACKEND address (backendIp on the camera's
subnet + the server's listen port), resolved by the same probe the push-IP
picker uses, NOT window.location (which is the SPA's dev/proxy origin). The
URL embeds the deviceId, so it needs a SAVED camera; and the backend IP needs
a Test connection first. We surface each field separately, matching the
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
{isCamera && Boolean(config.alarmPushEnabled) && (
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
{!editing?.id ? (
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
) : !backendIp || backendPort == null ? (
<p className="hint mt-1">{t("setup.alarmUrlTestFirst")}</p>
) : (
(() => {
const path = `/api/devices/hikvision/${editing.id}/event`;
// What the operator pastes into the camera's Alarm Settings form.
const fields: [string, string][] = [
[t("setup.alarmFieldHost"), backendIp],
[t("setup.alarmFieldUrl"), path],
[t("setup.alarmFieldProtocol"), "HTTP"],
[t("setup.alarmFieldPort"), String(backendPort)],
];
const copyText = fields.map(([k, v]) => `${k}: ${v}`).join("\n");
return (
<>
<div className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{fields.map(([k, v]) => (
<Fragment key={k}>
<span className="text-term-muted">{k}</span>
<code className="break-all rounded bg-term-panel px-2 py-0.5 text-term-green">{v}</code>
</Fragment>
))}
</div>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
className="btn btn-sm"
onClick={() => {
void navigator.clipboard?.writeText(copyText);
setAlarmUrlCopied(true);
setTimeout(() => setAlarmUrlCopied(false), 2000);
}}
>
{alarmUrlCopied ? t("setup.alarmUrlCopied") : t("setup.alarmUrlCopy")}
</button>
</div>
<p className="hint mt-1">{t("setup.alarmUrlHint")}</p>
</>
);
})()
)}
</div>
)}
{/* Test (no save/no device change) then Save (configures + persists). */} {/* Test (no save/no device change) then Save (configures + persists). */}
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}> <button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
+13
View File
@@ -406,6 +406,19 @@ export const en: Catalog = {
"anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.", "anprFail.vision-disabled": "Vision service is disabled — enable it (VISION_ENABLED) to test ANPR.",
"anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).", "anprFail.snapshot-failed": "Couldn't take a snapshot from the camera (offline or unreachable).",
"anprFail.no-plate": "No plate found in the snapshot.", "anprFail.no-plate": "No plate found in the snapshot.",
alarmUrlTitle: "Alarm Server settings (enter these in the camera)",
alarmUrlHint:
"Enter these in the camera at Configuration → Event → … → Alarm Settings (or Notify Surveillance Center). The camera POSTs every event here — no polling.",
alarmUrlCopy: "Copy all",
alarmUrlCopied: "Copied ✓",
alarmUrlSaveFirst:
"Save the camera first — the address is generated once the device has an ID. Re-open it for editing to see it.",
alarmUrlTestFirst:
"Click “Test connection” first — that resolves this host's IP on the camera's network (so the camera can reach it).",
alarmFieldHost: "Destination IP / Host",
alarmFieldUrl: "URL",
alarmFieldProtocol: "Protocol",
alarmFieldPort: "Port",
whichBarrier: "Which barrier does this device serve?", whichBarrier: "Which barrier does this device serve?",
controller: "Controller", controller: "Controller",
choose: "Choose…", choose: "Choose…",
+14
View File
@@ -416,6 +416,20 @@ export const sq = {
"anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.", "anprFail.vision-disabled": "Shërbimi i vizionit është çaktivizuar — aktivizoje (VISION_ENABLED) për ta testuar ANPR.",
"anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).", "anprFail.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
"anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.", "anprFail.no-plate": "Nuk u gjet asnjë targë në pamje.",
// Alarm Server push settings — generated for the camera's Event → Alarm Server form.
alarmUrlTitle: "Cilësimet e Alarm Server (vendosi te kamera)",
alarmUrlHint:
"Vendosi këto te kamera: Configuration → Event → … → Alarm Settings (ose Notify Surveillance Center). Kamera do të dërgojë çdo ngjarje këtu — pa polling.",
alarmUrlCopy: "Kopjo të gjitha",
alarmUrlCopied: "U kopjua ✓",
alarmUrlSaveFirst:
"Ruaje kamerën më parë — adresa gjenerohet pasi pajisja të marrë një ID. Hape sërish për editim që ta shohësh.",
alarmUrlTestFirst:
"Kliko “Testo lidhjen” më parë — kështu përcaktohet IP-ja e këtij hosti në rrjetin e kamerës (që kamera ta thërrasë).",
alarmFieldHost: "Destination IP / Host",
alarmFieldUrl: "URL",
alarmFieldProtocol: "Protokolli",
alarmFieldPort: "Porta",
// Binding picker. // Binding picker.
whichBarrier: "Cilën barrierë shërben kjo pajisje?", whichBarrier: "Cilën barrierë shërben kjo pajisje?",
controller: "Kontrolluesi", controller: "Kontrolluesi",