@7365admin1/layer-common 4.1.3-staging.253 → 4.1.3-staging.255

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,67 @@
15
15
  placeholder="Enter CCTV camera name"
16
16
  />
17
17
  </v-col>
18
- <v-col cols="12">
18
+ <v-col v-if="isCCTV" cols="12" class="mb-2">
19
+ <InputLabel
20
+ class="text-capitalize"
21
+ title="How do you have this camera's address?"
22
+ />
23
+ <v-radio-group
24
+ v-model="addressMode"
25
+ inline
26
+ hide-details
27
+ density="comfortable"
28
+ >
29
+ <v-radio label="From the recorder" value="recorder" />
30
+ <v-radio label="I already have a link" value="url" />
31
+ </v-radio-group>
32
+ </v-col>
33
+
34
+ <template v-if="showRecorderFields">
35
+ <v-col cols="12">
36
+ <InputLabel
37
+ class="text-capitalize"
38
+ title="Recorder address"
39
+ required
40
+ />
41
+ <v-text-field
42
+ v-model.trim="recorder.host"
43
+ density="comfortable"
44
+ :rules="[requiredRule]"
45
+ placeholder="Enter the recorder's address"
46
+ hint="The address of the recorder (NVR) this camera is plugged into, on the site network."
47
+ persistent-hint
48
+ />
49
+ </v-col>
50
+
51
+ <v-col cols="5" class="pr-2">
52
+ <InputLabel class="text-capitalize" title="Port" />
53
+ <v-text-field
54
+ v-model.trim="recorder.port"
55
+ density="comfortable"
56
+ :rules="[portRule]"
57
+ placeholder="Leave blank if unsure"
58
+ />
59
+ </v-col>
60
+
61
+ <v-col cols="7">
62
+ <InputLabel
63
+ class="text-capitalize"
64
+ title="Channel number"
65
+ required
66
+ />
67
+ <v-text-field
68
+ v-model.trim="recorder.channel"
69
+ density="comfortable"
70
+ :rules="[requiredRule, channelRule]"
71
+ placeholder="e.g. 6"
72
+ hint="The channel this camera sits on in the recorder's own list."
73
+ persistent-hint
74
+ />
75
+ </v-col>
76
+ </template>
77
+
78
+ <v-col v-if="showUrlField" cols="12">
19
79
  <InputLabel class="text-capitalize" title="URL" required />
20
80
  <v-text-field
21
81
  v-model.trim="camera.host"
@@ -26,6 +86,26 @@
26
86
  />
27
87
  </v-col>
28
88
 
89
+ <v-col v-if="isCCTV" cols="12" class="pt-2">
90
+ <v-btn
91
+ variant="outlined"
92
+ class="text-none"
93
+ :disabled="!canTest || disable"
94
+ :loading="testing"
95
+ @click="testCamera"
96
+ text="Test this camera"
97
+ />
98
+
99
+ <v-alert
100
+ v-if="testResult"
101
+ class="mt-3"
102
+ variant="tonal"
103
+ density="compact"
104
+ :type="testResult.ok ? 'success' : 'warning'"
105
+ :text="testResult.message"
106
+ />
107
+ </v-col>
108
+
29
109
  <!-- <v-col v-if="isANPR" cols="12">
30
110
  <InputLabel class="text-capitalize" title="Category" required />
31
111
  <v-select
@@ -232,6 +312,108 @@ const message = ref("");
232
312
  const isANPR = computed(() => prop.type === "anpr");
233
313
  const isCCTV = computed(() => prop.type === "ip");
234
314
 
315
+ const { addCamera, updateSiteCamera, testSiteCamera } = useSiteSettings();
316
+
317
+ /**
318
+ * Two ways to say where a camera is, and the guided one is the default.
319
+ *
320
+ * A CCTV camera's stored address is a relay player page. Nothing printed on a
321
+ * camera, and nothing in a recorder's own screens, will ever tell somebody what
322
+ * it is — until now it had to be requested from whoever runs the video service
323
+ * and pasted in. `recorder` collects what a person can genuinely find out and
324
+ * the server derives the address; `url` is the old field, kept because cameras
325
+ * configured that way must keep editing exactly as they do today and because
326
+ * some people do legitimately have a ready-made link.
327
+ *
328
+ * Editing an existing camera opens on `url`, so nothing about an already-working
329
+ * record changes shape under the person looking at it.
330
+ */
331
+ const addressMode = ref<"recorder" | "url">(
332
+ prop.type === "ip" && prop.mode !== "edit" ? "recorder" : "url"
333
+ );
334
+
335
+ const recorder = ref({ host: "", port: "", channel: "" });
336
+
337
+ const showRecorderFields = computed(
338
+ () => isCCTV.value && addressMode.value === "recorder"
339
+ );
340
+ const showUrlField = computed(
341
+ () => !isCCTV.value || addressMode.value === "url"
342
+ );
343
+
344
+ const portRule = (v: string | any) => {
345
+ if (!v) return true;
346
+ const port = Number(v);
347
+ return (Number.isInteger(port) && port >= 1 && port <= 65535) || "Enter a port number between 1 and 65535, or leave it blank.";
348
+ };
349
+
350
+ const channelRule = (v: string | any) => {
351
+ if (!v) return true;
352
+ const channel = Number(v);
353
+ return (
354
+ (Number.isInteger(channel) && channel >= 1 && channel <= 256) ||
355
+ "Enter the channel number, from 1 to 256."
356
+ );
357
+ };
358
+
359
+ /* ---------------------------- test before saving --------------------------- */
360
+
361
+ const testing = ref(false);
362
+ const testResult = ref<{ ok: boolean; message: string } | null>(null);
363
+
364
+ /**
365
+ * What the address fields currently say, in the shape the API takes.
366
+ *
367
+ * The same object is used for the test and for the save, so a green test is a
368
+ * test of the thing that gets saved and not of something near it.
369
+ */
370
+ function addressPayload() {
371
+ if (showRecorderFields.value) {
372
+ return {
373
+ host: "",
374
+ recorderHost: recorder.value.host,
375
+ recorderPort: recorder.value.port ? Number(recorder.value.port) : undefined,
376
+ channel: recorder.value.channel ? Number(recorder.value.channel) : undefined,
377
+ };
378
+ }
379
+
380
+ return { host: camera.value.host };
381
+ }
382
+
383
+ const canTest = computed(() => {
384
+ if (testing.value) return false;
385
+ if (showRecorderFields.value) {
386
+ return Boolean(recorder.value.host && recorder.value.channel);
387
+ }
388
+ return Boolean(camera.value.host);
389
+ });
390
+
391
+ // A verdict belongs to the address that produced it. Change the address and the
392
+ // old verdict is no longer about anything.
393
+ watch(
394
+ () => JSON.stringify(addressPayload()),
395
+ () => {
396
+ testResult.value = null;
397
+ }
398
+ );
399
+
400
+ async function testCamera() {
401
+ testing.value = true;
402
+ testResult.value = null;
403
+
404
+ try {
405
+ const result = await testSiteCamera(prop.site, addressPayload());
406
+ testResult.value = { ok: result.ok, message: result.message };
407
+ } catch (error: any) {
408
+ testResult.value = {
409
+ ok: false,
410
+ message: cameraErrorConverter(error, prop.type),
411
+ };
412
+ } finally {
413
+ testing.value = false;
414
+ }
415
+ }
416
+
235
417
  const anprCategoryOptions = computed(() => [
236
418
  { title: "Resident", value: "resident" },
237
419
  { title: "Visitor", value: "visitor" },
@@ -265,7 +447,10 @@ const hasChanges = computed(() => {
265
447
  camera.value.category !== prop.camera.category ||
266
448
  camera.value.guardPost !== prop.camera.guardPost ||
267
449
  camera.value.direction !== prop.camera.direction ||
268
- camera.value.name !== prop.camera.name
450
+ camera.value.name !== prop.camera.name ||
451
+ // Switching an existing camera over to the recorder fields is a change
452
+ // even though its stored URL has not been retyped.
453
+ showRecorderFields.value
269
454
  );
270
455
  }
271
456
 
@@ -273,20 +458,18 @@ const hasChanges = computed(() => {
273
458
  return true;
274
459
  });
275
460
 
276
- const { addCamera, updateSiteCamera } = useSiteSettings();
277
-
278
461
  async function submit() {
279
462
  disable.value = true;
280
463
  camera.value.guardPost = camera.value.guardPost ?? 0;
281
464
 
282
465
  try {
283
466
  if (prop.mode === "add") {
284
- await addCamera(camera.value);
467
+ await addCamera({ ...camera.value, ...addressPayload() });
285
468
  }
286
469
 
287
470
  if (prop.mode === "edit") {
288
471
  await updateSiteCamera(camera.value._id ?? "", {
289
- host: camera.value.host,
472
+ ...addressPayload(),
290
473
  username: camera.value.username,
291
474
  password: camera.value.password,
292
475
  category: camera.value.category,
@@ -54,6 +54,8 @@
54
54
  variant="outlined"
55
55
  density="compact"
56
56
  class="filter-field mb-4"
57
+ hint="Enter 0 for unlimited seats."
58
+ persistent-hint
57
59
  required
58
60
  >
59
61
  <template #label>
@@ -48,13 +48,56 @@ export default function () {
48
48
  }
49
49
  );
50
50
  }
51
- async function addCamera(camera: TSiteCamera) {
51
+ async function addCamera(camera: TSiteCamera & TCameraByRecorder) {
52
52
  return await useNuxtApp().$api<Record<string, any>>(`/api/site-cameras`, {
53
53
  method: "POST",
54
54
  body: camera,
55
55
  });
56
56
  }
57
57
 
58
+ /**
59
+ * Describe a camera by the recorder it is on instead of by a finished URL.
60
+ *
61
+ * The stored address is a relay player page, which nobody outside the video
62
+ * service can work out. These three fields are what a person can actually
63
+ * read off a recorder, and the server turns them into that address. Optional
64
+ * everywhere: sending `host` still works exactly as it always has.
65
+ */
66
+ type TCameraByRecorder = {
67
+ recorderHost?: string;
68
+ recorderPort?: number;
69
+ channel?: number;
70
+ };
71
+
72
+ /** What a "Test this camera" press comes back with. Never an address. */
73
+ type TCameraTestResult = {
74
+ ok: boolean;
75
+ status:
76
+ | "working"
77
+ | "no-video"
78
+ | "credential-refused"
79
+ | "unreachable"
80
+ | "cannot-test";
81
+ message: string;
82
+ };
83
+
84
+ /**
85
+ * Try an address before saving it. Read-only, and it runs on the server —
86
+ * the browser never touches a recorder and never sees a credential.
87
+ */
88
+ async function testSiteCamera(
89
+ siteId: string,
90
+ payload: { host?: string } & TCameraByRecorder
91
+ ) {
92
+ return await useNuxtApp().$api<TCameraTestResult>(
93
+ `/api/site-cameras/site/${siteId}/test`,
94
+ {
95
+ method: "POST",
96
+ body: payload,
97
+ }
98
+ );
99
+ }
100
+
58
101
  async function updateSiteCamera(
59
102
  id: string,
60
103
  value: Partial<
@@ -68,7 +111,8 @@ export default function () {
68
111
  | "direction"
69
112
  | "status"
70
113
  >
71
- >
114
+ > &
115
+ TCameraByRecorder
72
116
  ) {
73
117
  return await useNuxtApp().$api<Record<string, any>>(
74
118
  `/api/site-cameras/id/${id}`,
@@ -258,6 +302,7 @@ export default function () {
258
302
  getSiteUnits,
259
303
  updateSite,
260
304
  addCamera,
305
+ testSiteCamera,
261
306
  getAllSiteCameras,
262
307
  bulkImportSiteCameras,
263
308
  getSiteWall,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.1.3-staging.253",
5
+ "version": "4.1.3-staging.255",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",