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:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
assignDevice,
|
||||
@@ -376,6 +376,7 @@ function DeviceForm({
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
// ANPR probe (camera + anpr on): snapshot → vision analyze, reported below.
|
||||
const [alarmUrlCopied, setAlarmUrlCopied] = useState(false);
|
||||
const [anprResult, setAnprResult] = useState<AnprTestResult | null>(null);
|
||||
const [anprTesting, setAnprTesting] = useState(false);
|
||||
const [anprError, setAnprError] = useState<string | null>(null);
|
||||
@@ -387,22 +388,31 @@ function DeviceForm({
|
||||
|
||||
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
|
||||
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 ?? "") : "";
|
||||
useEffect(() => {
|
||||
if (!testedHost || !pushesToBackend) {
|
||||
setBackendIps(null);
|
||||
setBackendPort(null);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
fetchBackendIps(testedHost)
|
||||
.then(({ candidates }) => {
|
||||
.then(({ candidates, port }) => {
|
||||
if (!live) return;
|
||||
setBackendIps(candidates);
|
||||
setBackendPort(port);
|
||||
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setBackendIps(null);
|
||||
if (live) {
|
||||
setBackendIps(null);
|
||||
setBackendPort(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
@@ -730,6 +740,64 @@ function DeviceForm({
|
||||
</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). */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={test} disabled={testing}>
|
||||
|
||||
@@ -406,6 +406,19 @@ export const en: Catalog = {
|
||||
"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.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?",
|
||||
controller: "Controller",
|
||||
choose: "Choose…",
|
||||
|
||||
@@ -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.snapshot-failed": "Nuk u mor dot pamje nga kamera (jashtë linje ose e paarritshme).",
|
||||
"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.
|
||||
whichBarrier: "Cilën barrierë shërben kjo pajisje?",
|
||||
controller: "Kontrolluesi",
|
||||
|
||||
Reference in New Issue
Block a user