@7365admin1/layer-common 4.1.3-staging.254 → 4.1.3-staging.256

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,
@@ -72,7 +72,32 @@
72
72
  <p v-if="!cameras.length && !loading" class="vms__picker-empty">
73
73
  No cameras yet.
74
74
  </p>
75
- <label v-for="c in cameras" :key="c._id" class="vms__pick">
75
+
76
+ <!--
77
+ SEARCH, then a pager. A site with 150 cameras used to render 150
78
+ checkboxes in one 220 px column, so finding "Basement Ramp 2" meant
79
+ scrolling past everything else. Typing filters; the pager keeps the
80
+ column a fixed height whatever the site's size.
81
+
82
+ A plain input rather than a Vuetify field: this component is the wall
83
+ chrome, styled from its own CSS variables, and a `v-text-field` here
84
+ brings the app theme into a black surround that deliberately is not
85
+ themed.
86
+ -->
87
+ <input
88
+ v-if="cameras.length > PICKER_PAGE_SIZE"
89
+ v-model.trim="pickerSearch"
90
+ class="vms__picker-search"
91
+ type="search"
92
+ placeholder="Search cameras"
93
+ aria-label="Search cameras on this site"
94
+ >
95
+
96
+ <p v-if="cameras.length && !picker.matched" class="vms__picker-empty">
97
+ No camera matches that search.
98
+ </p>
99
+
100
+ <label v-for="c in picker.items" :key="c._id" class="vms__pick">
76
101
  <input
77
102
  type="checkbox"
78
103
  :checked="isPicked(c._id)"
@@ -85,6 +110,32 @@
85
110
  :class="c.status === 'active' ? 'vms__pick-dot--on' : 'vms__pick-dot--off'"
86
111
  />
87
112
  </label>
113
+
114
+ <!--
115
+ Shown whenever the matching list is longer than one page, so the reader
116
+ is never left believing the column is the whole list. The count is of
117
+ MATCHES, not of the site, because that is the list being paged.
118
+ -->
119
+ <div v-if="picker.pages > 1" class="vms__picker-pager">
120
+ <button
121
+ type="button"
122
+ class="vms__link"
123
+ :disabled="pickerPage === 0"
124
+ @click="pickerPage -= 1"
125
+ >
126
+ Prev
127
+ </button>
128
+ <span class="vms__picker-range">{{ picker.range }}</span>
129
+ <button
130
+ type="button"
131
+ class="vms__link"
132
+ :disabled="pickerPage >= picker.pages - 1"
133
+ @click="pickerPage += 1"
134
+ >
135
+ Next
136
+ </button>
137
+ </div>
138
+
88
139
  <p v-if="selectedIds.length >= layout.tiles" class="vms__picker-note">
89
140
  This layout holds {{ layout.tiles }}. Choose a bigger layout for more.
90
141
  </p>
@@ -293,6 +344,8 @@ import {
293
344
  focusedCamera,
294
345
  healthView,
295
346
  isDenseLayout,
347
+ PICKER_PAGE_SIZE,
348
+ pickerView,
296
349
  sharedCapabilityNote,
297
350
  wallCameras,
298
351
  wallLayout,
@@ -392,6 +445,8 @@ onMounted(load);
392
445
  watch(() => props.site, () => {
393
446
  selectedIds.value = [];
394
447
  page.value = 0;
448
+ pickerSearch.value = "";
449
+ pickerPage.value = 0;
395
450
  healthById.value = {};
396
451
  load();
397
452
  });
@@ -437,6 +492,46 @@ watch([layoutKey, pages], () => {
437
492
  page.value = clampPage(page.value, pages.value);
438
493
  });
439
494
 
495
+ /* ------------------------------------------------- browsing a large estate */
496
+
497
+ /**
498
+ * The picker is searched and paged, because at 150 cameras it was neither.
499
+ *
500
+ * It rendered one checkbox per camera in a 220 px column with no filter, so the
501
+ * only way to reach the last camera at a large site was to scroll past the other
502
+ * 149 - and nothing on screen said how many there were. This is the same
503
+ * treatment `CameraMain.vue` already gives the settings list.
504
+ *
505
+ * Searched and paged in the BROWSER on purpose: the wall endpoint answers with
506
+ * the whole site in one read (`getSiteWall` applies no limit, and the response is
507
+ * an id/name/status projection, not pictures), it has no search parameter, and a
508
+ * site's camera list is small enough to hold. If a site ever grows past what one
509
+ * request should carry, this wants a server-side search - and so does the
510
+ * endpoint.
511
+ *
512
+ * ponytail: a plain filter + slice, no virtual scroller. 150 rows of which 25
513
+ * are mounted is not a performance problem.
514
+ */
515
+ const pickerSearch = ref("");
516
+ const pickerPage = ref(0);
517
+
518
+ /**
519
+ * The whole picker column in one computed: the matching cameras, the page being
520
+ * shown, how many pages there are and the "26-50 of 150" label. The rule lives
521
+ * in `pickerView` so it can be tested; this only holds the two inputs.
522
+ */
523
+ const picker = computed(() =>
524
+ pickerView(cameras.value, pickerSearch.value, pickerPage.value, PICKER_PAGE_SIZE)
525
+ );
526
+
527
+ /**
528
+ * `pickerView` clamps the page for display, but the ref has to follow or the
529
+ * Prev/Next buttons act on a number the column is not on.
530
+ */
531
+ watch(picker, (view) => {
532
+ if (pickerPage.value !== view.page) pickerPage.value = view.page;
533
+ });
534
+
440
535
  function isPicked(id: string) {
441
536
  return selectedIds.value.includes(id);
442
537
  }
@@ -728,6 +823,39 @@ async function toggleFullscreen() {
728
823
  border-right: 1px solid var(--vms-line);
729
824
  }
730
825
 
826
+ .vms__picker-search {
827
+ width: 100%;
828
+ box-sizing: border-box;
829
+ margin: 0 0 6px;
830
+ /* 32 px so a thumb on a control-room touchscreen hits it. */
831
+ height: 32px;
832
+ padding: 0 8px;
833
+ font-size: 12px;
834
+ color: var(--vms-text);
835
+ background: rgba(255, 255, 255, 0.06);
836
+ border: 1px solid var(--vms-line);
837
+ border-radius: 4px;
838
+ }
839
+ .vms__picker-search::placeholder {
840
+ color: var(--vms-text-dim);
841
+ }
842
+ .vms__picker-pager {
843
+ display: flex;
844
+ align-items: center;
845
+ justify-content: space-between;
846
+ gap: 6px;
847
+ margin: 6px 0 0;
848
+ font-size: 11px;
849
+ }
850
+ .vms__picker-range {
851
+ color: var(--vms-text-dim);
852
+ }
853
+ .vms__picker-pager .vms__link[disabled] {
854
+ /* Dimmed, not hidden: a pager that loses its Prev button on page 1 moves Next
855
+ under the cursor and gets clicked by accident. */
856
+ opacity: 0.45;
857
+ cursor: default;
858
+ }
731
859
  .vms__picker-head {
732
860
  display: flex;
733
861
  align-items: center;
@@ -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.254",
5
+ "version": "4.1.3-staging.256",
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.",
@@ -169,6 +169,78 @@ export function clampPage(page: number, pages: number): number {
169
169
  return Math.min(Math.max(0, Math.floor(page)), Math.max(0, pages - 1));
170
170
  }
171
171
 
172
+ /** How many cameras the picker's side column shows at a time. */
173
+ export const PICKER_PAGE_SIZE = 25;
174
+
175
+ /**
176
+ * The camera browser beside the wall, searched and paged.
177
+ *
178
+ * **This exists because the picker was neither.** It rendered one checkbox per
179
+ * camera in a 220 px column with no filter and no count, so at a 150-camera site
180
+ * the only way to reach the last camera was to scroll past the other 149, and
181
+ * nothing on screen said how many there were. A supervisor who could not find a
182
+ * camera had no way to tell whether it was missing or merely further down.
183
+ *
184
+ * Searched and paged in the browser on purpose: the wall endpoint answers with
185
+ * the whole site in one read (`getSiteWall` applies no limit, and the response is
186
+ * an id/name/status projection rather than pictures) and has no search parameter.
187
+ *
188
+ * Extracted from the component so the paging is a tested rule rather than a
189
+ * template nobody would think to check - the playground build does not compile
190
+ * the wall's screens at all.
191
+ *
192
+ * `range` counts MATCHES, not the site, because that is the list being paged -
193
+ * but a search that hides cameras says so through `total` vs `matched`.
194
+ */
195
+ export function pickerView(
196
+ cameras: TWallCamera[],
197
+ query: string | undefined,
198
+ page: number,
199
+ pageSize: number = PICKER_PAGE_SIZE
200
+ ): {
201
+ matches: TWallCamera[];
202
+ items: TWallCamera[];
203
+ pages: number;
204
+ page: number;
205
+ range: string;
206
+ total: number;
207
+ matched: number;
208
+ } {
209
+ const all = Array.isArray(cameras) ? cameras : [];
210
+ const size = Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : PICKER_PAGE_SIZE;
211
+ const needle = String(query ?? "").trim().toLowerCase();
212
+
213
+ // Name first, id as the fallback label - the same thing the row displays, so
214
+ // searching for what you can read always finds it.
215
+ const matches = needle
216
+ ? all.filter((c: any) =>
217
+ String(c?.name || c?._id || "").toLowerCase().includes(needle)
218
+ )
219
+ : all;
220
+
221
+ const pages = Math.max(1, Math.ceil(matches.length / size));
222
+ // Clamped rather than trusted: typing can shrink the list under a page number
223
+ // that was valid a keystroke ago, and an out-of-range page renders as "no
224
+ // cameras", which reads as a broken picker rather than a stale page.
225
+ const current = clampPage(page, pages);
226
+ const start = current * size;
227
+ const items = matches.slice(start, start + size);
228
+
229
+ const range = matches.length
230
+ ? `${start + 1}-${Math.min(matches.length, start + size)} of ${matches.length}`
231
+ : "0 of 0";
232
+
233
+ return {
234
+ matches,
235
+ items,
236
+ pages,
237
+ page: current,
238
+ range,
239
+ total: all.length,
240
+ matched: matches.length,
241
+ };
242
+ }
243
+
172
244
  /**
173
245
  * The cameras on one page, in order. **Never padded.**
174
246
  *