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 { 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}>
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user