diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index 6c656d4..f7ddd53 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -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(null); // ANPR probe (camera + anpr on): snapshot → vision analyze, reported below. + const [alarmUrlCopied, setAlarmUrlCopied] = useState(false); const [anprResult, setAnprResult] = useState(null); const [anprTesting, setAnprTesting] = useState(false); const [anprError, setAnprError] = useState(null); @@ -387,22 +388,31 @@ function DeviceForm({ const [backendIps, setBackendIps] = useState(null); const [backendIp, setBackendIp] = useState(""); + // 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(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({ )} + {/* 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) && ( +
+
{t("setup.alarmUrlTitle")}
+ {!editing?.id ? ( +

{t("setup.alarmUrlSaveFirst")}

+ ) : !backendIp || backendPort == null ? ( +

{t("setup.alarmUrlTestFirst")}

+ ) : ( + (() => { + 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 ( + <> +
+ {fields.map(([k, v]) => ( + + {k} + {v} + + ))} +
+
+ +
+

{t("setup.alarmUrlHint")}

+ + ); + })() + )} +
+ )} + {/* Test (no save/no device change) then Save (configures + persists). */}