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

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.
@@ -0,0 +1,60 @@
1
+ ---
2
+ "@7365admin1/layer-common": patch
3
+ ---
4
+
5
+ Rewrite the camera wall's detail panel for the people who read it, and stop
6
+ repeating the same sentence four times.
7
+
8
+ The panel under the tiles was written for a developer. On the live wall it
9
+ read:
10
+
11
+ > BBQ AREA
12
+ > The video page loaded, but this browser cannot confirm a picture is arriving.
13
+ > The player belongs to the video service and a browser cannot look inside it.
14
+ > Judge the tile by what you can see, and report a blank or frozen picture.
15
+ > ● Recorder responding Last picture this server received: none yet
16
+ > Firmware and device clock are not available: this recorder is reachable over
17
+ > video only.
18
+
19
+ Every one of those statements is true, and the screen was still wrong: it
20
+ explained cross-origin isolation to a guard, repeated the camera's name the
21
+ tile already showed, put a green light beside the words "none yet", and printed
22
+ the same reason on four capability cards in a row.
23
+
24
+ Changes, all copy and layout — **no behaviour, no capability gating and no
25
+ permission check is touched**:
26
+
27
+ - **The badge reads "Not confirmed"**, not "Not verified". "Not verified"
28
+ describes a check we failed to make; "Not confirmed" describes the picture,
29
+ which is the thing the reader cares about. Same claim, and the wall still
30
+ refuses to say "Live" until the video service reports a decoded frame.
31
+ - **The sentence behind it is now an instruction**: "This page cannot confirm
32
+ the picture is arriving, so check the tile yourself. If it is blank or frozen,
33
+ report the camera." Why the software cannot confirm it is our problem, and it
34
+ has been removed from the product.
35
+ - **The camera's name is no longer a heading of its own.** It is the subject of
36
+ the status line — "BBQ AREA · Recorder responding" — so a reader on a 3x3 wall
37
+ still knows which tile the panel describes, without a second caption under the
38
+ first one.
39
+ - **"Last picture" is drawn only when there has been one.** A green "Recorder
40
+ responding" beside "none yet" read as a contradiction. They were never in
41
+ conflict, but a screen that has to be explained is wrong. The tile's own
42
+ health line has always behaved this way; the panel now matches it.
43
+ - **The firmware-and-device-clock sentence is gone**, from the panel and from
44
+ the tile's tooltip. This screen never showed either value, so explaining their
45
+ absence gave the reader nothing to do. The server still sends
46
+ `detailUnavailableReason`; nothing draws it.
47
+ - **One reason for the group instead of four copies.** When every switched-off
48
+ control is off for the same reason, that reason and its next step are stated
49
+ once above the row and each card keeps only its title and its state. When the
50
+ reasons differ — "the recorder is unreachable" and "this camera cannot move"
51
+ are two problems with two different people to ask — the group note is refused
52
+ and each card carries its own again. New rule 7, in `camera-wall.ts`, with
53
+ four tests.
54
+ - Shorter next-step and no-signal wording, and a subject on the recorder's
55
+ status labels ("Recorder not checked yet" rather than "Health not checked
56
+ yet").
57
+
58
+ Rules 3 and 6 are intact: the server's own sentence is still shown verbatim and
59
+ never rewritten, and the recorder's state is still a separate fact from the
60
+ tile's badge.
@@ -170,44 +170,73 @@
170
170
  ask for it, and hiding it is what stops anyone asking.
171
171
  -->
172
172
  <div v-if="focused" class="vms__caps">
173
- <p class="vms__caps-head">{{ focused.name || focused._id }}</p>
174
-
175
173
  <!--
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.
174
+ THE HEADER IS GONE, AND THE CAMERA'S NAME IS NOW THE SUBJECT OF A
175
+ SENTENCE INSTEAD.
176
+
177
+ This panel used to open with the camera's name on its own line, directly
178
+ under a tile already captioned with that name. The owner saw it on the
179
+ live wall and asked why the name was there twice a fair question,
180
+ because as a bare heading it was not doing anything the tile had not
181
+ already done.
182
+
183
+ It cannot simply be deleted: on a 3x3 wall the panel describes ONE of
184
+ nine tiles and the reader has to know which. So the name stays, once,
185
+ as the thing the status line is about — "BBQ AREA · Recorder
186
+ responding" is a statement, where "BBQ AREA" over "Recorder responding"
187
+ was a label followed by a fact about something unnamed.
183
188
  -->
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
189
  <div class="vms__health">
194
190
  <span class="vms__health-state">
195
191
  <span class="vms__health-dot" :class="`vms__health-dot--${focusedHealth.tone}`" />
196
- {{ focusedHealth.label }}
192
+ {{ focused.name || focused._id }}
197
193
  </span>
198
- <span class="vms__health-item">
199
- Last picture this server received:
200
- <b>{{ focusedHealth.lastFrame || "none yet" }}</b>
194
+ <!--
195
+ THE RECORDER's own state — a different fact from the tile badge above,
196
+ which is about the picture. They can disagree, and when they do the
197
+ reader needs both, not a merged verdict.
198
+ -->
199
+ <span class="vms__health-item">{{ focusedHealth.label }}</span>
200
+ <!--
201
+ "Last picture" ONLY WHEN THERE HAS BEEN ONE.
202
+
203
+ It used to read "Last picture this server received: none yet" beside a
204
+ green "Recorder responding", and the two together read as a
205
+ contradiction: a green light next to "none yet" tells a guard nothing
206
+ they can act on. They were never in conflict — one is the recorder
207
+ answering, the other is a still frame this server has never been set
208
+ up to fetch — but a screen that has to be explained is a screen that
209
+ is wrong. When there IS a picture, its age is worth knowing and it is
210
+ shown. When there is not, the line is silent, exactly as the tile's
211
+ own health line has always behaved.
212
+ -->
213
+ <span v-if="focusedHealth.lastFrame" class="vms__health-item">
214
+ Last picture <b>{{ focusedHealth.lastFrame }}</b>
201
215
  </span>
202
216
  <span v-if="focusedHealth.detail" class="vms__health-item">
203
217
  {{ focusedHealth.detail }}
204
218
  </span>
205
- <span v-if="focusedHealth.deviceDetail" class="vms__health-item">
206
- {{ focusedHealth.deviceDetail }}
207
- </span>
208
219
  <span v-if="healthFailed" class="vms__health-item">{{ healthFailed }}</span>
209
220
  </div>
210
221
 
222
+ <!--
223
+ WHAT THE TILE's BADGE MEANS, said once here rather than nine times
224
+ across the wall — and said in the reader's terms, not ours. See
225
+ `UNVERIFIED_DETAIL`.
226
+ -->
227
+ <p v-if="embedsPlayer" class="vms__badge-note">{{ UNVERIFIED_DETAIL }}</p>
228
+
229
+ <p class="vms__caps-head">Camera controls</p>
230
+ <!--
231
+ RULE 7. One reason, stated once, when it is genuinely one reason — see
232
+ `sharedCapabilityNote`. When the reasons differ the note is `null` and
233
+ every card carries its own, as before.
234
+ -->
235
+ <div v-if="sharedNote" class="vms__caps-note">
236
+ <p class="vms__cap-detail">{{ sharedNote.detail }}</p>
237
+ <p v-if="sharedNote.nextStep" class="vms__cap-next">{{ sharedNote.nextStep }}</p>
238
+ </div>
239
+
211
240
  <div class="vms__caps-row">
212
241
  <div
213
242
  v-for="c in WALL_CAPABILITY_CONTROLS"
@@ -224,17 +253,21 @@
224
253
  {{ capability(c.key).tag }}
225
254
  </span>
226
255
  </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>
256
+ <!--
257
+ Working: what it does HERE. Off: the server's sentence, verbatim
258
+ (rule 3) unless the whole group shares that sentence, in which
259
+ case it has already been said once above and the card carries only
260
+ its title and its tag.
261
+ -->
262
+ <p v-if="capabilityText(c)" class="vms__cap-detail">{{ capabilityText(c) }}</p>
231
263
  <!--
232
264
  RULE 5. The line the server cannot write, because it does not know
233
265
  who the reader can go and ask. Somebody read "Not set up yet" on this
234
266
  screen and asked how to set it up — which is a fair question that the
235
- screen was not answering.
267
+ screen was not answering. Suppressed here for the same reason as
268
+ the sentence above it: it is in the group note.
236
269
  -->
237
- <p v-if="capability(c.key).nextStep" class="vms__cap-next">
270
+ <p v-if="!sharedNote && capability(c.key).nextStep" class="vms__cap-next">
238
271
  {{ capability(c.key).nextStep }}
239
272
  </p>
240
273
  </div>
@@ -252,6 +285,7 @@ import {
252
285
  DEFAULT_WALL_LAYOUT,
253
286
  healthView,
254
287
  isDenseLayout,
288
+ sharedCapabilityNote,
255
289
  wallCameras,
256
290
  wallLayout,
257
291
  wallPageCount,
@@ -417,7 +451,29 @@ function capability(key: string) {
417
451
  }
418
452
 
419
453
  /**
420
- * Whether this camera embeds a player at all i.e. whether the "Not verified"
454
+ * Rule 7: the one reason the whole group is off, or `null` when they differ.
455
+ * When it is set, the cards stop repeating it — see the template.
456
+ */
457
+ const sharedNote = computed(() =>
458
+ sharedCapabilityNote(
459
+ focused.value,
460
+ WALL_CAPABILITY_CONTROLS.map((c) => c.key)
461
+ )
462
+ );
463
+
464
+ /**
465
+ * What one card says under its title: what the control does when it works, the
466
+ * server's reason when it does not, and NOTHING when that reason has already
467
+ * been given once for the group.
468
+ */
469
+ function capabilityText(control: (typeof WALL_CAPABILITY_CONTROLS)[number]) {
470
+ const view = capability(control.key);
471
+ if (view.available) return control.ready;
472
+ return sharedNote.value ? "" : view.detail;
473
+ }
474
+
475
+ /**
476
+ * Whether this camera embeds a player at all — i.e. whether the "Not confirmed"
421
477
  * badge is something its tile can ever show. A camera the server has already
422
478
  * refused has a reason of its own on the tile and does not need this one too.
423
479
  */
@@ -470,7 +526,7 @@ async function sweepHealth() {
470
526
  } catch {
471
527
  // Kept, not cleared: the last known health is still the most useful thing
472
528
  // 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.";
529
+ healthFailed.value = "Could not check the recorder just now.";
474
530
  }
475
531
  }
476
532
 
@@ -831,14 +887,31 @@ async function toggleFullscreen() {
831
887
  border-top: 1px solid var(--vms-line);
832
888
  }
833
889
 
890
+ /*
891
+ Now a GROUP LABEL rather than the camera's name repeated: it names what the
892
+ row below it is, which is the one job a heading here has. The camera is
893
+ identified once, in the status line above.
894
+ */
834
895
  .vms__caps-head {
835
- margin: 0 0 8px;
896
+ margin: 0 0 6px;
836
897
  font-size: 11px;
837
898
  letter-spacing: 0.06em;
838
899
  text-transform: uppercase;
839
900
  color: var(--vms-text-dim);
840
901
  }
841
902
 
903
+ /*
904
+ RULE 7's one-sentence-for-the-group. Sits between the group label and the
905
+ cards, indented on the same 2 px accent rail the cards wear, so it reads as
906
+ belonging to all of them rather than to the first one.
907
+ */
908
+ .vms__caps-note {
909
+ margin: 0 0 8px;
910
+ padding-left: 10px;
911
+ border-left: 2px solid var(--vms-accent);
912
+ max-width: 92ch;
913
+ }
914
+
842
915
  .vms__caps-row {
843
916
  display: grid;
844
917
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -178,10 +178,10 @@ import {
178
178
  * origin's document is blocked by the browser — correctly.
179
179
  *
180
180
  * That ceiling has not moved. What has changed is that the badge no longer
181
- * pretends otherwise. A loaded page earns **"Not verified"**, not "Live"; only
181
+ * pretends otherwise. A loaded page earns **"Not confirmed"**, not "Live"; only
182
182
  * a frame the video service itself reports over `postMessage` earns "Live".
183
183
  * The service sends nothing today, so every tile on this estate sits at "Not
184
- * verified" — which is the true answer. It went in because on the live wall two
184
+ * confirmed" — which is the true answer. It went in because on the live wall two
185
185
  * cameras rendered a blank rectangle under a green "Live" badge, which is the
186
186
  * one thing a monitoring screen must never do.
187
187
  *
@@ -239,10 +239,13 @@ const healthLine = computed(() => {
239
239
  return lastFrame && !props.dense ? `${label} · ${lastFrame}` : label;
240
240
  });
241
241
 
242
- /** Everything the line had no room for, on hover. */
243
- const healthTitle = computed(() =>
244
- [recorder.value.detail, recorder.value.deviceDetail].filter(Boolean).join(" ")
245
- );
242
+ /**
243
+ * Everything the line had no room for, on hover. The firmware-and-clock
244
+ * sentence used to be appended here too; it is gone for the same reason it is
245
+ * gone from the panel — this screen never showed either value, so explaining
246
+ * their absence gave the reader nothing to do. See `THealthView`.
247
+ */
248
+ const healthTitle = computed(() => recorder.value.detail || "");
246
249
 
247
250
  /* -------------------------------------------------------------------- zoom */
248
251
 
@@ -432,7 +435,7 @@ function restartFrameClock() {
432
435
  * direction.
433
436
  *
434
437
  * The timeout only fires while nothing at all has been heard: a page that
435
- * loaded is already at `page-up` and stays there. Sitting on "Not verified"
438
+ * loaded is already at `page-up` and stays there. Sitting on "Not confirmed"
436
439
  * indefinitely is deliberate — declaring a camera dead on a timer, with no
437
440
  * evidence, is the same mistake as declaring it live on a timer.
438
441
  */
@@ -66,6 +66,29 @@
66
66
  </template>
67
67
  </v-snackbar>
68
68
 
69
+ <!--
70
+ Camera health is its own alert, deliberately separate from the plate
71
+ snackbar above: it is not a visitor event, it must not be silenced by the
72
+ unregistered-visitor switch, and it must not be hidden on the unregistered
73
+ tab. Several failing cameras collapse into one line rather than one
74
+ snackbar each.
75
+ -->
76
+ <v-snackbar
77
+ v-if="canViewVisitor"
78
+ v-model="cameraAlert.modal"
79
+ :color="cameraFaults.size ? 'warning' : 'success'"
80
+ :timeout="10000"
81
+ location="top right"
82
+ class="rounded-xl"
83
+ multi-line
84
+ :z-index="100000000"
85
+ >
86
+ <span class="text-1-4rem">{{ cameraAlertText }}</span>
87
+ <template #actions>
88
+ <v-btn icon="mdi-close" variant="text" @click="cameraAlert.modal = false" />
89
+ </template>
90
+ </v-snackbar>
91
+
69
92
  <v-dialog v-model="permanentMessageDialog.modal" max-width="480" persistent>
70
93
  <v-card>
71
94
  <v-card-title class="text-h6">Notice</v-card-title>
@@ -132,6 +155,36 @@ const permanentMessageDialog = reactive({
132
155
  text: "",
133
156
  });
134
157
 
158
+ // Outstanding camera faults for this site, keyed by camera id so a repeat for
159
+ // the same camera replaces its line instead of adding another.
160
+ const cameraFaults = reactive(new Map<string, string>());
161
+ const cameraRecovery = ref("");
162
+ const cameraAlert = reactive({ modal: false });
163
+
164
+ const cameraAlertText = computed(
165
+ () => cameraAlertMessage([...cameraFaults.values()]) || cameraRecovery.value
166
+ );
167
+
168
+ const handleCameraHealth = (data: TVisitorSocketData) => {
169
+ // Older API-core builds send the fault text with no `event`, so a bare
170
+ // message is a fault.
171
+ if (data?.event === "camera-recovered") {
172
+ // Without a camera id there is no way to tell which fault cleared.
173
+ if (data?.camera) cameraFaults.delete(data.camera);
174
+ else cameraFaults.clear();
175
+ // Alerts are now infrequent, so the operator is told it came back —
176
+ // otherwise the only signal a camera recovered is silence.
177
+ cameraRecovery.value = cameraFaults.size ? "" : data?.message || "";
178
+ cameraAlert.modal = Boolean(cameraAlertText.value);
179
+ return;
180
+ }
181
+
182
+ if (!data?.message) return;
183
+ cameraRecovery.value = "";
184
+ cameraFaults.set(data?.camera || data.message, data.message);
185
+ cameraAlert.modal = true;
186
+ };
187
+
135
188
  watch(
136
189
  () => props.siteId,
137
190
  async (siteId) => {
@@ -233,8 +286,9 @@ const connectSocket = () => {
233
286
  }
234
287
 
235
288
 
236
- if (data?.message) {
237
- showTransientMessage(data.message, "error");
289
+ // Camera health, not a visitor event: its own alert, its own gate.
290
+ if (data?.message || data?.event) {
291
+ handleCameraHealth(data);
238
292
  }
239
293
 
240
294
  if (data?.messagePermanent) {
@@ -5,6 +5,32 @@ export type TVisitorSocketData = {
5
5
  message?: string;
6
6
  messagePermanent?: string;
7
7
  reload?: boolean;
8
+ /**
9
+ * Camera health. Sent alongside `message` by API-core so the client can group
10
+ * or clear an alert per camera instead of matching on the message text.
11
+ * Absent on older API-core builds — treat a bare `message` as a fault.
12
+ */
13
+ event?: "camera-fault" | "camera-recovered";
14
+ /** The camera the `event` is about. */
15
+ camera?: string;
16
+ };
17
+
18
+ /**
19
+ * One line for however many cameras are currently down.
20
+ *
21
+ * A site with several ANPR cameras could otherwise raise one alert per camera
22
+ * at the same moment. The single-camera case shows the server's own message,
23
+ * which names the camera; beyond that, naming them all in a snackbar is worse
24
+ * than sending the operator to the page that lists them.
25
+ */
26
+ export const cameraAlertMessage = (messages: string[]): string => {
27
+ if (!messages.length) return "";
28
+ if (messages.length === 1) return messages[0] as string;
29
+ return (
30
+ `${messages.length} cameras at this site are not responding. Plate reads ` +
31
+ `and automatic barrier opening are stopped for them. Open Site Settings > ` +
32
+ `Cameras to see which.`
33
+ );
8
34
  };
9
35
 
10
36
  export const useVisitorSocket = () => {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.1-staging.66",
5
+ "version": "3.2.1-staging.68",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -14,6 +14,7 @@
14
14
  "build": "nuxt build .playground",
15
15
  "generate": "nuxt generate .playground",
16
16
  "preview": "nuxt preview .playground",
17
+ "test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\"",
17
18
  "release": "yarn run build && changeset publish"
18
19
  },
19
20
  "devDependencies": {
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { cameraAlertMessage } from "./.build/useVisitorSocket.mjs";
5
+
6
+ /*
7
+ * The reported symptom was one camera raising a red toast every 10 seconds on
8
+ * an unrelated page. API-core now paces the alert; this side has to make sure
9
+ * several cameras failing at once still cannot become several snackbars.
10
+ */
11
+
12
+ test("nothing is shown when no camera is down", () => {
13
+ assert.equal(cameraAlertMessage([]), "");
14
+ });
15
+
16
+ test("one failing camera shows the server's own message, which names it", () => {
17
+ const text = "The entry and exit ANPR camera at Seventh Condominium is not responding.";
18
+ assert.equal(cameraAlertMessage([text]), text);
19
+ });
20
+
21
+ test("several failing cameras collapse into a single line", () => {
22
+ const many = ["camera one is down", "camera two is down", "camera three is down"];
23
+ const text = cameraAlertMessage(many);
24
+ assert.match(text, /^3 cameras at this site are not responding/);
25
+ for (const message of many) assert.doesNotMatch(text, new RegExp(message));
26
+ });
27
+
28
+ test("the collapsed line points at the page that lists them", () => {
29
+ assert.match(cameraAlertMessage(["a", "b"]), /Site Settings > Cameras/);
30
+ });
31
+
32
+ test("no camera message tells the operator to deactivate anything", () => {
33
+ for (const messages of [["a camera is down"], ["a", "b"]]) {
34
+ assert.doesNotMatch(cameraAlertMessage(messages), /inactive|deactivat/i);
35
+ }
36
+ });
@@ -11,6 +11,7 @@ import {
11
11
  isDenseLayout,
12
12
  playerOrigin,
13
13
  playerSignal,
14
+ sharedCapabilityNote,
14
15
  tileView,
15
16
  wallCameras,
16
17
  wallLayout,
@@ -134,6 +135,66 @@ test("a switched-off control says what would switch it on and who to ask", () =>
134
135
  assert.match(v.nextStep, /not something to switch on from this page/i);
135
136
  });
136
137
 
138
+ /* ---------------------------------------------------------------- rule 7 */
139
+
140
+ const OFF_FOR_THE_SAME_REASON = {
141
+ state: "unsupported",
142
+ reason: "device-http-not-configured",
143
+ detail: "No direct connection to this camera's recorder is configured on this server.",
144
+ };
145
+ const CAP_KEYS = WALL_CAPABILITY_CONTROLS.map((c) => c.key);
146
+
147
+ test("four controls off for one reason state that reason once", () => {
148
+ // What the owner actually saw: the same sentence and the same next step,
149
+ // four times in a row, under four different titles.
150
+ const cam = ip({
151
+ capabilities: {
152
+ // Exactly the live wall: zoom works, the other four are off together.
153
+ digitalZoom: { state: "supported" },
154
+ playback: OFF_FOR_THE_SAME_REASON,
155
+ events: OFF_FOR_THE_SAME_REASON,
156
+ ptz: OFF_FOR_THE_SAME_REASON,
157
+ presets: OFF_FOR_THE_SAME_REASON,
158
+ },
159
+ });
160
+ const note = sharedCapabilityNote(cam, CAP_KEYS);
161
+ assert.match(note.detail, /^No direct connection/);
162
+ assert.match(note.nextStep, /Seven365 administrator/);
163
+ });
164
+
165
+ test("controls off for DIFFERENT reasons are never merged into one", () => {
166
+ // "The recorder is unreachable" and "this camera cannot move" are two
167
+ // problems with two different people to ask. Flattening them would be a lie
168
+ // of convenience, so the group note is refused and each card keeps its own.
169
+ const cam = ip({
170
+ capabilities: {
171
+ playback: OFF_FOR_THE_SAME_REASON,
172
+ ptz: { state: "unsupported", reason: "device-no-ptz", detail: "This camera cannot move." },
173
+ },
174
+ });
175
+ assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
176
+ });
177
+
178
+ test("one control off on its own does not get a group note", () => {
179
+ const cam = ip({
180
+ capabilities: {
181
+ playback: OFF_FOR_THE_SAME_REASON,
182
+ events: { state: "supported" },
183
+ ptz: { state: "supported" },
184
+ presets: { state: "supported" },
185
+ digitalZoom: { state: "supported" },
186
+ },
187
+ });
188
+ assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
189
+ });
190
+
191
+ test("a camera with no descriptor at all still groups its unknowns", () => {
192
+ // Every control reads "Not checked yet" with the same not-probed sentence —
193
+ // which is four identical cards, the same noise, and the same fix.
194
+ const note = sharedCapabilityNote(ip(), CAP_KEYS);
195
+ assert.match(note.detail, /has not been asked/);
196
+ });
197
+
137
198
  test("a reason code this build has never seen still names somebody to ask", () => {
138
199
  const cam = ip({
139
200
  capabilities: { ptz: { state: "unsupported", reason: "invented-later", detail: "Nope." } },
@@ -175,15 +236,19 @@ test("a page that has not loaded is Connecting, never Live", () => {
175
236
  * green "Live". The page had loaded; no picture was arriving. A loaded page is
176
237
  * now worth exactly one thing, and it is not the word "Live".
177
238
  */
178
- test("a loaded player page is NOT Live - it is Not verified", () => {
239
+ test("a loaded player page is NOT Live - it is Not confirmed", () => {
179
240
  const v = tileView(ip(), { player: "page-up" });
180
241
  assert.equal(v.state, "unverified");
181
- assert.equal(v.label, "Not verified");
242
+ assert.equal(v.label, "Not confirmed");
182
243
  // No scrim: there may be a perfectly good picture under that badge, and
183
244
  // covering a working camera would be a worse lie than the one being fixed.
184
245
  assert.equal(v.detail, null);
185
246
  assert.equal(v.showPlayer, true);
186
- assert.match(v.note, /cannot confirm a picture is arriving/);
247
+ assert.match(v.note, /cannot confirm the picture is arriving/);
248
+ // Plain English, and an instruction rather than an explanation: the reader is
249
+ // a guard, not a browser engineer. Nothing about origins, players or frames.
250
+ assert.match(v.note, /check the tile/i);
251
+ assert.doesNotMatch(v.note, /browser|origin|player|iframe|service/i);
187
252
  });
188
253
 
189
254
  test("only a reported frame earns the word Live", () => {
@@ -349,16 +414,44 @@ test("reachability alone is enough when the server sends no summary word", () =>
349
414
  assert.equal(healthView({ reachable: false }).tone, "down");
350
415
  });
351
416
 
352
- test("the server's health sentences are carried through, not rewritten", () => {
417
+ test("the server's health sentence is carried through, not rewritten", () => {
353
418
  const v = healthView({
354
419
  health: "unreachable",
355
420
  reachable: false,
356
421
  reason: "The recorder did not respond.",
422
+ });
423
+ assert.equal(v.detail, "The recorder did not respond.");
424
+ });
425
+
426
+ /**
427
+ * The firmware-and-clock sentence is the server explaining why two values it
428
+ * never showed are blank. It was drawn on the panel and appended to the tile's
429
+ * tooltip; a guard can do nothing with a firmware version, so it is not carried
430
+ * at all now. Pinned, because "soften it" was the tempting answer and the wrong
431
+ * one.
432
+ */
433
+ test("the firmware and device-clock sentence is not carried to the screen", () => {
434
+ const v = healthView({
435
+ health: "ok",
436
+ reachable: true,
357
437
  detailUnavailableReason:
358
438
  "Firmware and device clock are not available: this recorder is reachable over video only.",
359
439
  });
360
- assert.equal(v.detail, "The recorder did not respond.");
361
- assert.match(v.deviceDetail, /^Firmware and device clock/);
440
+ assert.equal(v.detail, null);
441
+ assert.equal("deviceDetail" in v, false);
442
+ });
443
+
444
+ /**
445
+ * The complaint that started this: a green "Recorder responding" beside "Last
446
+ * picture this server received: none yet" reads as a contradiction. It is not
447
+ * one — but the panel now simply says nothing when there has never been a
448
+ * picture, which is the only reading a guard can act on.
449
+ */
450
+ test("a recorder that is up with no picture yet reports no picture line at all", () => {
451
+ const v = healthView({ health: "ok", reachable: true, lastFrameAt: null });
452
+ assert.equal(v.tone, "ok");
453
+ assert.equal(v.label, "Recorder responding");
454
+ assert.equal(v.lastFrame, null);
362
455
  });
363
456
 
364
457
  test("a picture's age is coarse above a minute, and never a fake time", () => {
@@ -256,15 +256,15 @@ const NOT_PROBED = "This camera has not been asked what it can do yet.";
256
256
  */
257
257
  export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
258
258
  "device-http-not-configured":
259
- "This is a server setting, not something to switch on from this page: the server needs a network route to the camera's recorder, plus the recorder's sign-in details. Ask your Seven365 administrator.",
259
+ "Not something to switch on from this page it needs a change on the server. Ask your Seven365 administrator.",
260
260
  "device-http-disabled":
261
- "Direct camera access is switched off on this server. Ask your Seven365 administrator to switch it on.",
261
+ "Switched off on the server. Ask your Seven365 administrator to switch it on.",
262
262
  "device-http-unreachable":
263
- "The recorder did not answer this server. Ask your Seven365 administrator or the camera installer to check the recorder and its network.",
263
+ "The recorder is not answering. Ask your Seven365 administrator or the camera installer to check it.",
264
264
  "device-http-locked-out":
265
- "Sign-ins to the recorder are being held back so its account is not locked out. Ask your Seven365 administrator to check the recorder's sign-in details.",
265
+ "Sign-ins to the recorder are being held back so its account is not locked out. Ask your Seven365 administrator.",
266
266
  "control-not-enabled":
267
- "Camera control is switched off on this server. Switching it on moves real hardware, so it has to be approved by a Seven365 backend lead first.",
267
+ "Switched off on the server. It moves real hardware, so a Seven365 backend lead has to approve it first.",
268
268
  "device-no-ptz":
269
269
  "This camera cannot move, so there is nothing to switch on. A different camera would be needed.",
270
270
  "no-recorder-configured":
@@ -282,7 +282,7 @@ export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
282
282
  "device-not-probed":
283
283
  "The camera is asked what it can do the first time it is opened. Nothing to do — check back in a moment.",
284
284
  "no-transport":
285
- "Nothing on this server can do this yet. It has to be built before it can be switched on.",
285
+ "Not built yet. Nothing on this server can do it.",
286
286
  };
287
287
 
288
288
  /**
@@ -386,6 +386,47 @@ export function capabilityView(
386
386
  };
387
387
  }
388
388
 
389
+ /**
390
+ * RULE 7 — SAY IT ONCE FOR THE GROUP WHEN IT IS ONE FACT, NOT FOUR.
391
+ *
392
+ * On this estate all four of the switched-off controls are off for the SAME
393
+ * reason: there is no direct connection to the recorder. So the panel drew four
394
+ * cards, each carrying an identical sentence and an identical next step —
395
+ * eight lines of repetition saying one thing. Four copies of a sentence do not
396
+ * make it four facts; they make the reader skim, and skimming is how the one
397
+ * line that mattered got missed.
398
+ *
399
+ * So when every unavailable control shares the same reason, this returns it and
400
+ * the panel prints it once above the row. Each card keeps its own title and its
401
+ * own tag, so the individual state is never lost — only the echo is.
402
+ *
403
+ * Returns `null` the moment the reasons DIFFER, which is the case that must not
404
+ * be flattened: "the recorder is unreachable" and "this camera cannot move" are
405
+ * two different problems with two different people to ask, and merging them
406
+ * would be a lie of convenience. Also `null` when nothing is off (nothing to
407
+ * explain) or when only one control is off (a group note for a group of one is
408
+ * just the card, moved).
409
+ */
410
+ export function sharedCapabilityNote(
411
+ camera: TWallCamera | null | undefined,
412
+ keys: readonly string[]
413
+ ): { detail: string; nextStep: string | null } | null {
414
+ const off = keys
415
+ .map((key) => capabilityView(camera, key))
416
+ .filter((view) => !view.available);
417
+
418
+ if (off.length < 2) return null;
419
+
420
+ const first = off[0];
421
+ const same = off.every(
422
+ (view) => view.detail === first.detail && view.nextStep === first.nextStep
423
+ );
424
+
425
+ return same && first.detail
426
+ ? { detail: first.detail, nextStep: first.nextStep }
427
+ : null;
428
+ }
429
+
389
430
  /* -------------------------------------------------------------------------- */
390
431
  /* Tile state */
391
432
  /* -------------------------------------------------------------------------- */
@@ -428,10 +469,16 @@ export type TTileState =
428
469
  *
429
470
  * `unverified` is the state this wall is in for every camera on this estate
430
471
  * today, and saying so is the point — see the note above `tileView`.
472
+ *
473
+ * It used to read "Not verified", which is a word from our side of the fence:
474
+ * it describes a check we did not manage to make. "Not confirmed" describes the
475
+ * PICTURE, which is the only thing the reader cares about, and it is the same
476
+ * claim. The instruction that goes with it — check the tile — is in
477
+ * `UNVERIFIED_DETAIL`, on the badge's tooltip and once on the panel.
431
478
  */
432
479
  export const TILE_LABELS = {
433
480
  live: "Live",
434
- unverified: "Not verified",
481
+ unverified: "Not confirmed",
435
482
  connecting: "Connecting…",
436
483
  "no-signal": "No signal",
437
484
  offline: "No connection",
@@ -548,12 +595,23 @@ export type TTileView = {
548
595
  };
549
596
 
550
597
  /**
551
- * The sentence behind "Not verified", said once on the focused camera's panel
552
- * rather than nine times across the wall. Invented here: no server has a
553
- * reason code for "your browser cannot see inside another origin's page".
598
+ * The sentence behind "Not confirmed", said once on the focused camera's panel
599
+ * rather than nine times across the wall.
600
+ *
601
+ * **Written for a guard or a property manager, not for us.** The first version
602
+ * of this line explained cross-origin isolation — "the player belongs to the
603
+ * video service and a browser cannot look inside it" — which is true, is the
604
+ * reason, and is none of the reader's business. The owner read it on the live
605
+ * wall and said so. A reader of this screen has exactly three questions: is
606
+ * this camera working, what do I do if it is not, and what can I do here. WHY
607
+ * the software cannot answer the first one is our problem, not theirs.
608
+ *
609
+ * So: what we cannot do, then what they should do. Nothing else. The claim is
610
+ * every bit as honest as the paragraph it replaces — it just stops making the
611
+ * reader sit through an engineering lesson to get to the instruction.
554
612
  */
555
613
  export const UNVERIFIED_DETAIL =
556
- "The video page loaded, but this browser cannot confirm a picture is arriving. The player belongs to the video service and a browser cannot look inside it. Judge the tile by what you can see, and report a blank or frozen picture.";
614
+ "This page cannot confirm the picture is arriving, so check the tile yourself. If it is blank or frozen, report the camera.";
557
615
 
558
616
  /**
559
617
  * Rule 4, plus every reason a tile may have nothing to show.
@@ -632,14 +690,14 @@ export function tileView(
632
690
  // we genuinely cannot tell a dead camera from a dead video service.
633
691
  return showing(
634
692
  "no-signal",
635
- "The live view did not load. The camera or the video service may be down."
693
+ "No picture. The camera or the video service may be down. Report it if it stays like this."
636
694
  );
637
695
  case "stalled":
638
696
  // It WAS playing and stopped. That is the one case where we can say a
639
697
  // picture has genuinely gone away, because we saw one arrive first.
640
698
  return showing(
641
699
  "no-signal",
642
- "The picture stopped arriving. The camera or the video service may have dropped."
700
+ "The picture stopped arriving. The camera or the video service may have dropped. Report it if it does not come back."
643
701
  );
644
702
  case "playing":
645
703
  return showing("live", null);
@@ -688,15 +746,27 @@ export type THealthView = {
688
746
  detail: string | null;
689
747
  /** "4 min ago", or `null` when this server has never had a picture. */
690
748
  lastFrame: string | null;
691
- /** Why firmware and clock are blank. The server's sentence, verbatim. */
692
- deviceDetail: string | null;
693
749
  };
694
750
 
751
+ /*
752
+ THE FIRMWARE-AND-CLOCK LINE IS GONE, AND IT IS NOT COMING BACK.
753
+
754
+ The server sends `detailUnavailableReason` — "Firmware and device clock are
755
+ not available: this recorder is reachable over video only." — so that two
756
+ blank fields do not read as "checked, and fine". That is a good reason to
757
+ send it and a bad reason to DRAW it: this screen never showed firmware or a
758
+ device clock in the first place, so the sentence was explaining the absence
759
+ of something the reader had not been offered and could not use. A guard
760
+ cannot act on a firmware version. Softening the wording would have kept the
761
+ noise, so the line is removed instead. The field is still on
762
+ `TWallCameraHealth` because the API sends it; nothing on the wall draws it.
763
+ */
764
+
695
765
  const HEALTH_LABELS: Record<string, { tone: THealthTone; label: string }> = {
696
766
  ok: { tone: "ok", label: "Recorder responding" },
697
767
  drifted: { tone: "warn", label: "Recorder clock is out" },
698
768
  unreachable: { tone: "down", label: "Recorder not responding" },
699
- unsupported: { tone: "unknown", label: "Not checked" },
769
+ unsupported: { tone: "unknown", label: "Recorder not checked" },
700
770
  };
701
771
 
702
772
  /**
@@ -716,7 +786,7 @@ export function healthView(
716
786
  ? HEALTH_LABELS.ok
717
787
  : health?.reachable === false
718
788
  ? HEALTH_LABELS.unreachable
719
- : { tone: "unknown", label: "Health not checked yet" };
789
+ : { tone: "unknown", label: "Recorder not checked yet" };
720
790
 
721
791
  const { tone, label } = known ?? fallback;
722
792
 
@@ -725,7 +795,6 @@ export function healthView(
725
795
  label,
726
796
  detail: (health?.reason || "").trim() || null,
727
797
  lastFrame: agoLabel(health?.lastFrameAt, now),
728
- deviceDetail: (health?.detailUnavailableReason || "").trim() || null,
729
798
  };
730
799
  }
731
800