@7365admin1/layer-common 3.2.1-staging.64 → 3.2.1-staging.66

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.
@@ -119,18 +119,48 @@
119
119
  }"
120
120
  >
121
121
  <CameraWallTile
122
- v-for="(camera, i) in tiles"
123
- :key="camera ? camera._id : `empty-${i}`"
122
+ v-for="camera in tiles"
123
+ :key="camera._id"
124
124
  :camera="camera"
125
125
  :dense="dense"
126
126
  :online="online"
127
- :focused="!!camera && camera._id === focusedId"
127
+ :health="healthById[camera._id]"
128
+ :now="now"
129
+ :focused="camera._id === focusedId"
128
130
  @focus="focusedId = $event._id"
129
131
  />
130
132
  </div>
131
133
  </div>
132
134
  </div>
133
135
 
136
+ <!--
137
+ THE PAGER.
138
+
139
+ Fixed under the wall rather than scrolling with the page, because it must
140
+ not scroll away from the wall it pages. Drawn only when there is more than
141
+ one page — a site with four cameras on a 2x2 wall does not need to be told
142
+ it is on page 1 of 1.
143
+ -->
144
+ <div v-if="pages > 1" class="vms__pager">
145
+ <button
146
+ type="button"
147
+ class="vms__tool"
148
+ :disabled="page === 0"
149
+ @click="page = Math.max(0, page - 1)"
150
+ >
151
+ Previous
152
+ </button>
153
+ <span class="vms__pager-label">Page {{ page + 1 }} of {{ pages }}</span>
154
+ <button
155
+ type="button"
156
+ class="vms__tool"
157
+ :disabled="page >= pages - 1"
158
+ @click="page = Math.min(pages - 1, page + 1)"
159
+ >
160
+ Next
161
+ </button>
162
+ </div>
163
+
134
164
  <!-- ------------------------------------------------------- capabilities -->
135
165
  <!--
136
166
  THE CONTROLS THAT ARE NOT SWITCHED ON YET.
@@ -141,6 +171,43 @@
141
171
  -->
142
172
  <div v-if="focused" class="vms__caps">
143
173
  <p class="vms__caps-head">{{ focused.name || focused._id }}</p>
174
+
175
+ <!--
176
+ HEALTH — the recorder's own state, which is a different fact from the
177
+ tile badge above it (that one is about the video page loading). They can
178
+ disagree, and when they do a supervisor needs both, not a merged verdict.
179
+
180
+ Firmware and the device clock are blank on this estate and the server
181
+ says why, in its own words. An empty field with no explanation reads as
182
+ "checked, and fine", which would be the opposite of the truth.
183
+ -->
184
+ <!--
185
+ WHAT THE BADGE ON THE TILE ACTUALLY MEANS, said once here rather than
186
+ nine times across the wall. "Not verified" is a word a supervisor has
187
+ every right to challenge, and the answer — the picture belongs to
188
+ another origin's page and a browser cannot look inside it — is too long
189
+ for a tile and too important to leave out.
190
+ -->
191
+ <p v-if="embedsPlayer" class="vms__badge-note">{{ UNVERIFIED_DETAIL }}</p>
192
+
193
+ <div class="vms__health">
194
+ <span class="vms__health-state">
195
+ <span class="vms__health-dot" :class="`vms__health-dot--${focusedHealth.tone}`" />
196
+ {{ focusedHealth.label }}
197
+ </span>
198
+ <span class="vms__health-item">
199
+ Last picture this server received:
200
+ <b>{{ focusedHealth.lastFrame || "none yet" }}</b>
201
+ </span>
202
+ <span v-if="focusedHealth.detail" class="vms__health-item">
203
+ {{ focusedHealth.detail }}
204
+ </span>
205
+ <span v-if="focusedHealth.deviceDetail" class="vms__health-item">
206
+ {{ focusedHealth.deviceDetail }}
207
+ </span>
208
+ <span v-if="healthFailed" class="vms__health-item">{{ healthFailed }}</span>
209
+ </div>
210
+
144
211
  <div class="vms__caps-row">
145
212
  <div
146
213
  v-for="c in WALL_CAPABILITY_CONTROLS"
@@ -150,11 +217,26 @@
150
217
  >
151
218
  <p class="vms__cap-title">
152
219
  {{ c.title }}
153
- <span v-if="capability(c.key).tag" class="vms__cap-tag">
220
+ <span
221
+ class="vms__cap-tag"
222
+ :class="{ 'vms__cap-tag--on': capability(c.key).available }"
223
+ >
154
224
  {{ capability(c.key).tag }}
155
225
  </span>
156
226
  </p>
157
- <p class="vms__cap-detail">{{ capability(c.key).detail }}</p>
227
+ <!-- Working: what it does HERE. Off: the server's sentence, verbatim. -->
228
+ <p class="vms__cap-detail">
229
+ {{ capability(c.key).available ? c.ready : capability(c.key).detail }}
230
+ </p>
231
+ <!--
232
+ RULE 5. The line the server cannot write, because it does not know
233
+ who the reader can go and ask. Somebody read "Not set up yet" on this
234
+ screen and asked how to set it up — which is a fair question that the
235
+ screen was not answering.
236
+ -->
237
+ <p v-if="capability(c.key).nextStep" class="vms__cap-next">
238
+ {{ capability(c.key).nextStep }}
239
+ </p>
158
240
  </div>
159
241
  </div>
160
242
  </div>
@@ -166,14 +248,21 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
166
248
 
167
249
  import {
168
250
  capabilityView,
251
+ clampPage,
169
252
  DEFAULT_WALL_LAYOUT,
253
+ healthView,
170
254
  isDenseLayout,
171
255
  wallCameras,
172
256
  wallLayout,
257
+ wallPageCount,
258
+ wallRows,
173
259
  wallTiles,
260
+ tileView,
261
+ UNVERIFIED_DETAIL,
174
262
  WALL_CAPABILITY_CONTROLS,
175
263
  WALL_LAYOUTS,
176
264
  type TWallCamera,
265
+ type TWallCameraHealth,
177
266
  type TWallLayoutKey,
178
267
  } from "../utils/camera-wall";
179
268
 
@@ -215,11 +304,17 @@ const props = defineProps<{ site: string }>();
215
304
 
216
305
  /* ---------------------------------------------------------------- the data */
217
306
 
218
- const { getSiteWall } = useSiteSettings();
307
+ const { getSiteWall, getSiteHealth } = useSiteSettings();
219
308
 
220
309
  const cameras = ref<TWallCamera[]>([]);
221
310
  const loading = ref(true);
222
311
  const failed = ref("");
312
+ /**
313
+ * The server's own tuning. `healthCacheSeconds` is how long IT caches a
314
+ * recorder probe, so it is exactly how often there is any point asking — and
315
+ * asking faster would spend round trips to be handed the same cached answer.
316
+ */
317
+ const config = ref<Record<string, any> | null>(null);
223
318
 
224
319
  async function load() {
225
320
  loading.value = true;
@@ -230,6 +325,7 @@ async function load() {
230
325
  // an ANPR unit must never reach a monitoring wall even if that ever changes.
231
326
  const res = await getSiteWall(props.site);
232
327
  cameras.value = wallCameras(res?.cameras);
328
+ config.value = res?.config ?? null;
233
329
  } catch (error: any) {
234
330
  // The raw message is often a stack trace and rarely something an operator
235
331
  // can act on — but "you may not see this site" and "your connection
@@ -246,12 +342,15 @@ async function load() {
246
342
  : "The list of cameras did not load. Check your connection and try again.";
247
343
  } finally {
248
344
  loading.value = false;
345
+ restartHealth();
249
346
  }
250
347
  }
251
348
 
252
349
  onMounted(load);
253
350
  watch(() => props.site, () => {
254
351
  selectedIds.value = [];
352
+ page.value = 0;
353
+ healthById.value = {};
255
354
  load();
256
355
  });
257
356
 
@@ -259,13 +358,42 @@ watch(() => props.site, () => {
259
358
 
260
359
  const layoutKey = ref<TWallLayoutKey>(DEFAULT_WALL_LAYOUT);
261
360
  const layout = computed(() => wallLayout(layoutKey.value));
262
- const rows = computed(() => Math.ceil(layout.value.tiles / layout.value.columns));
263
361
  const dense = computed(() => isDenseLayout(layoutKey.value));
264
362
 
265
363
  const selectedIds = ref<string[]>([]);
266
364
  const showPicker = ref(false);
267
365
 
268
- const tiles = computed(() => wallTiles(cameras.value, selectedIds.value, layoutKey.value));
366
+ const page = ref(0);
367
+ const pages = computed(() =>
368
+ wallPageCount(cameras.value, selectedIds.value, layoutKey.value)
369
+ );
370
+
371
+ const tiles = computed(() =>
372
+ wallTiles(cameras.value, selectedIds.value, layoutKey.value, page.value)
373
+ );
374
+
375
+ /**
376
+ * THE GRID PACKS TO WHAT IS ON IT.
377
+ *
378
+ * Five cameras on a nine-tile wall used to draw five pictures and four dashed
379
+ * boxes reading "No camera in this position", with the pictures squeezed into
380
+ * two thirds of the height to leave room for the holes. The layout picker means
381
+ * "at most this many, at this size" — it does not mean the screen owes anybody
382
+ * four empty rectangles. Nothing can be done with an empty cell from here
383
+ * anyway: cameras are added in Site Settings.
384
+ */
385
+ const rows = computed(() => wallRows(tiles.value.length, layoutKey.value));
386
+
387
+ /**
388
+ * A layout change resizes the page, so the old page NUMBER no longer means
389
+ * anything — going to 3x3 from page 2 of a 2x2 wall would land somewhere
390
+ * arbitrary, or past the end. Clamped rather than reset to 0: dropping from
391
+ * 3x3 to 2x2 keeps you roughly where you were instead of throwing you back to
392
+ * the front of a wall you were part-way through.
393
+ */
394
+ watch([layoutKey, pages], () => {
395
+ page.value = clampPage(page.value, pages.value);
396
+ });
269
397
 
270
398
  function isPicked(id: string) {
271
399
  return selectedIds.value.includes(id);
@@ -281,13 +409,90 @@ function togglePick(id: string) {
281
409
 
282
410
  const focusedId = ref("");
283
411
  const focused = computed(
284
- () => tiles.value.find((c) => c && c._id === focusedId.value) || tiles.value.find(Boolean) || null
412
+ () => tiles.value.find((c) => c._id === focusedId.value) || tiles.value[0] || null
285
413
  );
286
414
 
287
415
  function capability(key: string) {
288
416
  return capabilityView(focused.value, key);
289
417
  }
290
418
 
419
+ /**
420
+ * Whether this camera embeds a player at all — i.e. whether the "Not verified"
421
+ * badge is something its tile can ever show. A camera the server has already
422
+ * refused has a reason of its own on the tile and does not need this one too.
423
+ */
424
+ const embedsPlayer = computed(
425
+ () => tileView(focused.value, { online: online.value }).showPlayer
426
+ );
427
+
428
+ /* ------------------------------------------------------------------ health */
429
+
430
+ /**
431
+ * THE RECORDER'S OWN STATE, asked for separately and slowly.
432
+ *
433
+ * This is the only thing on this screen that can cause the server to touch a
434
+ * device, so three things bound it and all three matter:
435
+ *
436
+ * - **only the tiles on screen** are asked about, so paging to another page
437
+ * does not keep probing the page you left;
438
+ * - **the interval is the server's own cache lifetime**, because asking faster
439
+ * than the cache is round trips in exchange for the identical answer;
440
+ * - **a failure is never retried and never breaks the wall.** Health is
441
+ * supporting information; a wall that goes blank because a probe timed out
442
+ * would be a worse screen than one with no health on it at all.
443
+ */
444
+ const healthById = ref<Record<string, TWallCameraHealth>>({});
445
+ const healthFailed = ref("");
446
+ /** Ticked with the sweep so "4 min ago" ages instead of freezing. */
447
+ const now = ref(Date.now());
448
+
449
+ const focusedHealth = computed(() =>
450
+ healthView(focused.value ? healthById.value[focused.value._id] : null, now.value)
451
+ );
452
+
453
+ const visibleIds = computed(() => tiles.value.map((c) => c._id));
454
+
455
+ let healthTimer: ReturnType<typeof setInterval> | null = null;
456
+
457
+ async function sweepHealth() {
458
+ const ids = visibleIds.value;
459
+ now.value = Date.now();
460
+ if (!ids.length || !online.value) return;
461
+
462
+ try {
463
+ const res = await getSiteHealth(props.site, ids);
464
+ const next: Record<string, TWallCameraHealth> = {};
465
+ for (const item of (res?.cameras ?? []) as any[]) {
466
+ if (item?._id) next[String(item._id)] = item as TWallCameraHealth;
467
+ }
468
+ healthById.value = next;
469
+ healthFailed.value = "";
470
+ } catch {
471
+ // Kept, not cleared: the last known health is still the most useful thing
472
+ // we have, and "the recorder was fine a moment ago" is a real answer.
473
+ healthFailed.value = "The camera health check did not answer this time.";
474
+ }
475
+ }
476
+
477
+ function restartHealth() {
478
+ if (healthTimer) clearInterval(healthTimer);
479
+ const seconds = Number(config.value?.healthCacheSeconds);
480
+ const intervalMs = (Number.isFinite(seconds) && seconds > 0 ? seconds : 20) * 1000;
481
+ healthTimer = setInterval(sweepHealth, intervalMs);
482
+ sweepHealth();
483
+ }
484
+
485
+ // Re-swept when the visible set changes, so a supervisor who pages or changes
486
+ // layout is not reading the previous page's health under the new page's names.
487
+ watch(visibleIds, (ids, previous) => {
488
+ if (ids.join(",") !== (previous ?? []).join(",")) sweepHealth();
489
+ });
490
+
491
+ onBeforeUnmount(() => {
492
+ if (healthTimer) clearInterval(healthTimer);
493
+ healthTimer = null;
494
+ });
495
+
291
496
  /* ------------------------------------------------------- browser condition */
292
497
 
293
498
  const online = ref(true);
@@ -339,7 +544,13 @@ async function toggleFullscreen() {
339
544
  --vms-scrim-solid: rgba(7, 9, 12, 0.82);
340
545
  --vms-text: #e8ecf1;
341
546
  --vms-text-dim: #94a0b0;
342
- --vms-text-faint: #5c6675;
547
+ /*
548
+ #5c6675 measured 2.98:1 against the chrome — under the 3:1 a status
549
+ indicator needs, and this token IS a status indicator (the "not checked"
550
+ dot and the inactive dot in the camera list). #7a8697 measures 4.68:1 there
551
+ and 5.01:1 on a tile. Invented value; the rest of the ramp is unchanged.
552
+ */
553
+ --vms-text-faint: #7a8697;
343
554
  --vms-accent: #5b9be0;
344
555
  --vms-radius: 3px;
345
556
 
@@ -538,6 +749,80 @@ async function toggleFullscreen() {
538
749
  color: var(--vms-text-dim);
539
750
  }
540
751
 
752
+ /* ------------------------------------------------------------------ pager */
753
+
754
+ .vms__pager {
755
+ display: flex;
756
+ align-items: center;
757
+ justify-content: center;
758
+ gap: 10px;
759
+ padding: 6px;
760
+ background: var(--vms-chrome);
761
+ border-top: 1px solid var(--vms-line);
762
+ }
763
+
764
+ .vms__pager-label {
765
+ font-size: 12px;
766
+ color: var(--vms-text-dim);
767
+ font-variant-numeric: tabular-nums;
768
+ }
769
+
770
+ /* ----------------------------------------------------------- badge meaning */
771
+
772
+ .vms__badge-note {
773
+ margin: 0 0 8px;
774
+ max-width: 92ch;
775
+ font-size: 12px;
776
+ line-height: 17px;
777
+ color: var(--vms-text-dim);
778
+ }
779
+
780
+ /* ----------------------------------------------------------------- health */
781
+
782
+ .vms__health {
783
+ display: flex;
784
+ flex-wrap: wrap;
785
+ align-items: baseline;
786
+ gap: 6px 16px;
787
+ margin: 0 0 10px;
788
+ font-size: 12px;
789
+ line-height: 17px;
790
+ color: var(--vms-text-dim);
791
+ }
792
+
793
+ .vms__health-state {
794
+ display: inline-flex;
795
+ align-items: center;
796
+ gap: 6px;
797
+ color: var(--vms-text);
798
+ font-weight: 600;
799
+ }
800
+
801
+ .vms__health-item b {
802
+ color: var(--vms-text);
803
+ font-weight: 600;
804
+ }
805
+
806
+ .vms__health-dot {
807
+ width: 7px;
808
+ height: 7px;
809
+ border-radius: 50%;
810
+ flex: none;
811
+ }
812
+
813
+ .vms__health-dot--ok {
814
+ background: #4caf50;
815
+ }
816
+ .vms__health-dot--warn {
817
+ background: #fb8c00;
818
+ }
819
+ .vms__health-dot--down {
820
+ background: #e0241c;
821
+ }
822
+ .vms__health-dot--unknown {
823
+ background: var(--vms-text-faint);
824
+ }
825
+
541
826
  /* ------------------------------------------------------------ capabilities */
542
827
 
543
828
  .vms__caps {
@@ -598,15 +883,40 @@ async function toggleFullscreen() {
598
883
  }
599
884
 
600
885
  /*
601
- 0.55, not the Material 0.38. Every switched-off control here carries a
602
- SENTENCE, and 0.38 measures about 3.3:1 against this surface under the 4.5:1
603
- a sentence needs. 0.55 measures about 5.2:1. A reason nobody can read is the
604
- same as no reason at all.
886
+ "Ready" is the one tag that is good news, and it is the accent rather than
887
+ green: green on this screen means "this camera is up", and a control being
888
+ available is not the same claim.
605
889
  */
606
- .vms__cap--off .vms__cap-detail {
607
- opacity: 0.55;
890
+ .vms__cap-tag--on {
891
+ color: var(--vms-accent);
892
+ }
893
+
894
+ /*
895
+ RULE 5's second line: quieter than the server's sentence above it, because it
896
+ is advice rather than fact — but NOT dimmed with the rest of a switched-off
897
+ panel. It is the one line on an unavailable control that is actionable, and
898
+ fading the only actionable line is the whole mistake this fixes.
899
+ */
900
+ .vms__cap-next {
901
+ margin: 6px 0 0;
902
+ padding-top: 6px;
903
+ border-top: 1px solid var(--vms-line);
904
+ font-size: 11px;
905
+ line-height: 16px;
906
+ color: var(--vms-text-dim);
608
907
  }
609
908
 
909
+ /*
910
+ THE FADE ON A SWITCHED-OFF REASON IS GONE, AND THE OLD COMMENT WAS WRONG.
911
+
912
+ It claimed 0.55 opacity measured "about 5.2:1". Measured properly — the dim
913
+ ramp #94a0b0 composited at 0.55 over the chrome #161b22 — it is 2.88:1,
914
+ well under the 4.5:1 a sentence needs, and the reason a supervisor is meant
915
+ to read was the least legible text on the panel. Removing the fade entirely
916
+ leaves #94a0b0 on #161b22 at 6.52:1. The panel is already outlined rather
917
+ than filled, which is what says "not switched on"; the fade was saying it a
918
+ second time at the cost of the words.
919
+ */
610
920
  .vms__cap-detail {
611
921
  margin: 0;
612
922
  font-size: 12px;