@7365admin1/layer-common 3.2.1-staging.67 → 3.2.1-staging.69
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.
- package/.changeset/camera-wall-plain-english.md +60 -0
- package/.changeset/camera-wall-selection-and-guidance.md +65 -0
- package/components/CameraWall.vue +136 -37
- package/components/CameraWallTile.vue +44 -8
- package/package.json +1 -1
- package/utils/camera-wall.test.ts +173 -7
- package/utils/camera-wall.ts +140 -20
|
@@ -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.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
"@7365admin1/layer-common": patch
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Mark the selected camera on the wall, and stop telling the reader to raise a
|
|
6
|
+
ticket without saying what for.
|
|
7
|
+
|
|
8
|
+
Two things the owner found on the live wall after the last copy pass. Both are
|
|
9
|
+
copy and presentation only — **no behaviour, no capability gating, no permission
|
|
10
|
+
check and no request is changed.**
|
|
11
|
+
|
|
12
|
+
**1. The camera's name was still printed twice.**
|
|
13
|
+
|
|
14
|
+
The tile said `BBQ AREA` and the panel underneath said it again. The last pass
|
|
15
|
+
merged the panel's heading into its status line, which changed the layout and
|
|
16
|
+
left the repetition exactly where it was.
|
|
17
|
+
|
|
18
|
+
The name was there to answer "which of these nine tiles is this panel about?".
|
|
19
|
+
That is a real question and a name is the wrong answer to it — it makes the
|
|
20
|
+
reader scan the wall for a matching caption. So:
|
|
21
|
+
|
|
22
|
+
- **the selected tile is now visibly selected**: a 3 px accent ring with a scrim
|
|
23
|
+
hairline inside it, so the ring holds against a bright picture as well as a
|
|
24
|
+
dark one, plus the accent underline on the name chip that the toolbar already
|
|
25
|
+
uses to mark a selected tool. Both read at 1x1, 2x2 and 3x3;
|
|
26
|
+
- **the camera's name is removed from the panel entirely**. The panel opens on
|
|
27
|
+
the recorder's state — "● Recorder responding";
|
|
28
|
+
- `aria-current` marks the same tile for anyone not looking at the ring.
|
|
29
|
+
|
|
30
|
+
The highlight had never worked in the most common case, which is why the name
|
|
31
|
+
was load-bearing: it was bound to the raw clicked id, and before anyone clicks —
|
|
32
|
+
or after paging — nothing on the wall was marked while the panel was already
|
|
33
|
+
describing the first tile. It is now bound to the tile the panel actually
|
|
34
|
+
resolves to, so the two can never disagree.
|
|
35
|
+
|
|
36
|
+
**The panel can never be about a tile that is off screen**, so it never needs to
|
|
37
|
+
name one: the focused camera is resolved out of the current PAGE, so paging away
|
|
38
|
+
from a selection re-resolves to the first tile of the page in front of you and
|
|
39
|
+
the ring follows. Paging back restores the original selection. Extracted as
|
|
40
|
+
`focusedCamera()` in `camera-wall.ts` with four tests, including the 5-cameras
|
|
41
|
+
-across-2-pages case.
|
|
42
|
+
|
|
43
|
+
**2. "Ask your Seven365 administrator" was a dead end.**
|
|
44
|
+
|
|
45
|
+
It was the whole of the advice under every switched-off control, and it tells
|
|
46
|
+
the administrator nothing and a property manager only to go and wait. The screen
|
|
47
|
+
never said what the request should ask for.
|
|
48
|
+
|
|
49
|
+
The advice now names the two changes that are actually missing, because they are
|
|
50
|
+
two different jobs for two different people: **the site's network has to let our
|
|
51
|
+
server reach the camera recorder**, and **that recorder then has to be added to
|
|
52
|
+
the server's configuration**. Both are stated in plain words, with who arranges
|
|
53
|
+
them, and with the fact that neither is switchable from this page.
|
|
54
|
+
|
|
55
|
+
No environment variable name appears in any of it — those belong in
|
|
56
|
+
`iservice365-core/docs/camera-integration-config.md`, not on a property
|
|
57
|
+
manager's screen — and there is a test that keeps it that way. The other reason
|
|
58
|
+
codes (`device-http-disabled`, `device-http-unreachable`, `device-http-locked-out`,
|
|
59
|
+
`control-not-enabled`, `no-recorder-configured`, and the unknown-code fallback)
|
|
60
|
+
get the same treatment; the Site Settings ones already said what to do and are
|
|
61
|
+
unchanged.
|
|
62
|
+
|
|
63
|
+
The truthful-status rules are untouched: the wall still refuses to say "Live"
|
|
64
|
+
until the video service reports a decoded frame, and the recorder's state is
|
|
65
|
+
still a separate fact from the tile's badge.
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
:online="online"
|
|
127
127
|
:health="healthById[camera._id]"
|
|
128
128
|
:now="now"
|
|
129
|
-
:focused="camera._id ===
|
|
129
|
+
:focused="camera._id === focused?._id"
|
|
130
130
|
@focus="focusedId = $event._id"
|
|
131
131
|
/>
|
|
132
132
|
</div>
|
|
@@ -170,44 +170,80 @@
|
|
|
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
|
-
<!--
|
|
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
173
|
<!--
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
174
|
+
THE CAMERA'S NAME IS GONE FROM THIS PANEL ENTIRELY. THE SELECTED TILE
|
|
175
|
+
SAYS WHICH CAMERA THIS IS.
|
|
176
|
+
|
|
177
|
+
It was here twice before: once as a heading, then — after a first
|
|
178
|
+
attempt at this — as the subject of the status line. The owner's point
|
|
179
|
+
was never the line break. It was the REPETITION: the tile directly above
|
|
180
|
+
is already captioned "BBQ AREA", and printing it again underneath adds
|
|
181
|
+
no fact.
|
|
182
|
+
|
|
183
|
+
The reason it was kept is real — the panel describes ONE of up to nine
|
|
184
|
+
tiles and the reader has to know which — but a name is the wrong way to
|
|
185
|
+
answer it. Naming a tile makes the reader search the wall for a matching
|
|
186
|
+
caption; an accent ring makes the wall answer at a glance. So the ring
|
|
187
|
+
is the identification (`vms-tile--focused`) and the panel is free to be
|
|
188
|
+
about the camera rather than about naming it.
|
|
189
|
+
|
|
190
|
+
**And the panel can never describe a tile that is off screen**, so it
|
|
191
|
+
never needs to fall back to naming one. `focused` is resolved out of
|
|
192
|
+
`tiles` — the CURRENT page — so paging away re-resolves it to the first
|
|
193
|
+
tile of the page you are now looking at. There is no state in which this
|
|
194
|
+
panel is about something you cannot see. That is asserted in
|
|
195
|
+
`camera-wall.test.ts` so it cannot quietly stop being true.
|
|
190
196
|
-->
|
|
191
|
-
<p v-if="embedsPlayer" class="vms__badge-note">{{ UNVERIFIED_DETAIL }}</p>
|
|
192
|
-
|
|
193
197
|
<div class="vms__health">
|
|
198
|
+
<!--
|
|
199
|
+
THE RECORDER's own state — a different fact from the tile badge above,
|
|
200
|
+
which is about the picture. They can disagree, and when they do the
|
|
201
|
+
reader needs both, not a merged verdict.
|
|
202
|
+
-->
|
|
194
203
|
<span class="vms__health-state">
|
|
195
204
|
<span class="vms__health-dot" :class="`vms__health-dot--${focusedHealth.tone}`" />
|
|
196
205
|
{{ focusedHealth.label }}
|
|
197
206
|
</span>
|
|
198
|
-
|
|
199
|
-
Last picture
|
|
200
|
-
|
|
207
|
+
<!--
|
|
208
|
+
"Last picture" ONLY WHEN THERE HAS BEEN ONE.
|
|
209
|
+
|
|
210
|
+
It used to read "Last picture this server received: none yet" beside a
|
|
211
|
+
green "Recorder responding", and the two together read as a
|
|
212
|
+
contradiction: a green light next to "none yet" tells a guard nothing
|
|
213
|
+
they can act on. They were never in conflict — one is the recorder
|
|
214
|
+
answering, the other is a still frame this server has never been set
|
|
215
|
+
up to fetch — but a screen that has to be explained is a screen that
|
|
216
|
+
is wrong. When there IS a picture, its age is worth knowing and it is
|
|
217
|
+
shown. When there is not, the line is silent, exactly as the tile's
|
|
218
|
+
own health line has always behaved.
|
|
219
|
+
-->
|
|
220
|
+
<span v-if="focusedHealth.lastFrame" class="vms__health-item">
|
|
221
|
+
Last picture <b>{{ focusedHealth.lastFrame }}</b>
|
|
201
222
|
</span>
|
|
202
223
|
<span v-if="focusedHealth.detail" class="vms__health-item">
|
|
203
224
|
{{ focusedHealth.detail }}
|
|
204
225
|
</span>
|
|
205
|
-
<span v-if="focusedHealth.deviceDetail" class="vms__health-item">
|
|
206
|
-
{{ focusedHealth.deviceDetail }}
|
|
207
|
-
</span>
|
|
208
226
|
<span v-if="healthFailed" class="vms__health-item">{{ healthFailed }}</span>
|
|
209
227
|
</div>
|
|
210
228
|
|
|
229
|
+
<!--
|
|
230
|
+
WHAT THE TILE's BADGE MEANS, said once here rather than nine times
|
|
231
|
+
across the wall — and said in the reader's terms, not ours. See
|
|
232
|
+
`UNVERIFIED_DETAIL`.
|
|
233
|
+
-->
|
|
234
|
+
<p v-if="embedsPlayer" class="vms__badge-note">{{ UNVERIFIED_DETAIL }}</p>
|
|
235
|
+
|
|
236
|
+
<p class="vms__caps-head">Camera controls</p>
|
|
237
|
+
<!--
|
|
238
|
+
RULE 7. One reason, stated once, when it is genuinely one reason — see
|
|
239
|
+
`sharedCapabilityNote`. When the reasons differ the note is `null` and
|
|
240
|
+
every card carries its own, as before.
|
|
241
|
+
-->
|
|
242
|
+
<div v-if="sharedNote" class="vms__caps-note">
|
|
243
|
+
<p class="vms__cap-detail">{{ sharedNote.detail }}</p>
|
|
244
|
+
<p v-if="sharedNote.nextStep" class="vms__cap-next">{{ sharedNote.nextStep }}</p>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
211
247
|
<div class="vms__caps-row">
|
|
212
248
|
<div
|
|
213
249
|
v-for="c in WALL_CAPABILITY_CONTROLS"
|
|
@@ -224,17 +260,21 @@
|
|
|
224
260
|
{{ capability(c.key).tag }}
|
|
225
261
|
</span>
|
|
226
262
|
</p>
|
|
227
|
-
<!--
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
263
|
+
<!--
|
|
264
|
+
Working: what it does HERE. Off: the server's sentence, verbatim
|
|
265
|
+
(rule 3) — unless the whole group shares that sentence, in which
|
|
266
|
+
case it has already been said once above and the card carries only
|
|
267
|
+
its title and its tag.
|
|
268
|
+
-->
|
|
269
|
+
<p v-if="capabilityText(c)" class="vms__cap-detail">{{ capabilityText(c) }}</p>
|
|
231
270
|
<!--
|
|
232
271
|
RULE 5. The line the server cannot write, because it does not know
|
|
233
272
|
who the reader can go and ask. Somebody read "Not set up yet" on this
|
|
234
273
|
screen and asked how to set it up — which is a fair question that the
|
|
235
|
-
screen was not answering.
|
|
274
|
+
screen was not answering. Suppressed here for the same reason as
|
|
275
|
+
the sentence above it: it is in the group note.
|
|
236
276
|
-->
|
|
237
|
-
<p v-if="capability(c.key).nextStep" class="vms__cap-next">
|
|
277
|
+
<p v-if="!sharedNote && capability(c.key).nextStep" class="vms__cap-next">
|
|
238
278
|
{{ capability(c.key).nextStep }}
|
|
239
279
|
</p>
|
|
240
280
|
</div>
|
|
@@ -250,8 +290,10 @@ import {
|
|
|
250
290
|
capabilityView,
|
|
251
291
|
clampPage,
|
|
252
292
|
DEFAULT_WALL_LAYOUT,
|
|
293
|
+
focusedCamera,
|
|
253
294
|
healthView,
|
|
254
295
|
isDenseLayout,
|
|
296
|
+
sharedCapabilityNote,
|
|
255
297
|
wallCameras,
|
|
256
298
|
wallLayout,
|
|
257
299
|
wallPageCount,
|
|
@@ -407,17 +449,57 @@ function togglePick(id: string) {
|
|
|
407
449
|
|
|
408
450
|
/* --------------------------------------------------------- what is focused */
|
|
409
451
|
|
|
452
|
+
/**
|
|
453
|
+
* WHICH TILE THE PANEL IS ABOUT — and it is always a tile you can see.
|
|
454
|
+
*
|
|
455
|
+
* `focusedId` is what somebody clicked; `focused` is what the panel actually
|
|
456
|
+
* describes, and the two are deliberately not the same thing. It is resolved out
|
|
457
|
+
* of `tiles`, which is the CURRENT PAGE, so:
|
|
458
|
+
*
|
|
459
|
+
* - before anyone clicks, the panel describes the first tile on the wall;
|
|
460
|
+
* - paging away from your selection re-resolves to the first tile of the page
|
|
461
|
+
* you are now on, rather than describing a camera that is no longer drawn;
|
|
462
|
+
* - paging back returns to your selection, because `focusedId` is not cleared.
|
|
463
|
+
*
|
|
464
|
+
* **The tile highlight is bound to `focused`, not to `focusedId`** — that one
|
|
465
|
+
* character was the whole defect behind "the panel repeats the tile's name".
|
|
466
|
+
* Bound to `focusedId`, the fallback cases above lit NO tile at all: the panel
|
|
467
|
+
* described the first camera on the page and nothing on the wall said so, which
|
|
468
|
+
* is exactly why the name had to be printed in the panel to make sense of it.
|
|
469
|
+
* Bound to `focused`, the wall always shows which tile is being described, and
|
|
470
|
+
* the name in the panel becomes the repetition the owner said it was.
|
|
471
|
+
*/
|
|
410
472
|
const focusedId = ref("");
|
|
411
|
-
const focused = computed(
|
|
412
|
-
() => tiles.value.find((c) => c._id === focusedId.value) || tiles.value[0] || null
|
|
413
|
-
);
|
|
473
|
+
const focused = computed(() => focusedCamera(tiles.value, focusedId.value));
|
|
414
474
|
|
|
415
475
|
function capability(key: string) {
|
|
416
476
|
return capabilityView(focused.value, key);
|
|
417
477
|
}
|
|
418
478
|
|
|
419
479
|
/**
|
|
420
|
-
*
|
|
480
|
+
* Rule 7: the one reason the whole group is off, or `null` when they differ.
|
|
481
|
+
* When it is set, the cards stop repeating it — see the template.
|
|
482
|
+
*/
|
|
483
|
+
const sharedNote = computed(() =>
|
|
484
|
+
sharedCapabilityNote(
|
|
485
|
+
focused.value,
|
|
486
|
+
WALL_CAPABILITY_CONTROLS.map((c) => c.key)
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* What one card says under its title: what the control does when it works, the
|
|
492
|
+
* server's reason when it does not, and NOTHING when that reason has already
|
|
493
|
+
* been given once for the group.
|
|
494
|
+
*/
|
|
495
|
+
function capabilityText(control: (typeof WALL_CAPABILITY_CONTROLS)[number]) {
|
|
496
|
+
const view = capability(control.key);
|
|
497
|
+
if (view.available) return control.ready;
|
|
498
|
+
return sharedNote.value ? "" : view.detail;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Whether this camera embeds a player at all — i.e. whether the "Not confirmed"
|
|
421
503
|
* badge is something its tile can ever show. A camera the server has already
|
|
422
504
|
* refused has a reason of its own on the tile and does not need this one too.
|
|
423
505
|
*/
|
|
@@ -470,7 +552,7 @@ async function sweepHealth() {
|
|
|
470
552
|
} catch {
|
|
471
553
|
// Kept, not cleared: the last known health is still the most useful thing
|
|
472
554
|
// we have, and "the recorder was fine a moment ago" is a real answer.
|
|
473
|
-
healthFailed.value = "
|
|
555
|
+
healthFailed.value = "Could not check the recorder just now.";
|
|
474
556
|
}
|
|
475
557
|
}
|
|
476
558
|
|
|
@@ -831,14 +913,31 @@ async function toggleFullscreen() {
|
|
|
831
913
|
border-top: 1px solid var(--vms-line);
|
|
832
914
|
}
|
|
833
915
|
|
|
916
|
+
/*
|
|
917
|
+
Now a GROUP LABEL rather than the camera's name repeated: it names what the
|
|
918
|
+
row below it is, which is the one job a heading here has. The camera is
|
|
919
|
+
identified once, in the status line above.
|
|
920
|
+
*/
|
|
834
921
|
.vms__caps-head {
|
|
835
|
-
margin: 0 0
|
|
922
|
+
margin: 0 0 6px;
|
|
836
923
|
font-size: 11px;
|
|
837
924
|
letter-spacing: 0.06em;
|
|
838
925
|
text-transform: uppercase;
|
|
839
926
|
color: var(--vms-text-dim);
|
|
840
927
|
}
|
|
841
928
|
|
|
929
|
+
/*
|
|
930
|
+
RULE 7's one-sentence-for-the-group. Sits between the group label and the
|
|
931
|
+
cards, indented on the same 2 px accent rail the cards wear, so it reads as
|
|
932
|
+
belonging to all of them rather than to the first one.
|
|
933
|
+
*/
|
|
934
|
+
.vms__caps-note {
|
|
935
|
+
margin: 0 0 8px;
|
|
936
|
+
padding-left: 10px;
|
|
937
|
+
border-left: 2px solid var(--vms-accent);
|
|
938
|
+
max-width: 92ch;
|
|
939
|
+
}
|
|
940
|
+
|
|
842
941
|
.vms__caps-row {
|
|
843
942
|
display: grid;
|
|
844
943
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
:class="{ 'vms-tile--dense': dense, 'vms-tile--focused': focused, 'vms-tile--empty': !camera }"
|
|
6
6
|
:tabindex="camera ? 0 : -1"
|
|
7
7
|
:aria-label="camera ? `${camera.name || camera._id}, ${view.label}` : 'Empty position'"
|
|
8
|
+
:aria-current="focused ? 'true' : undefined"
|
|
8
9
|
@click="camera && $emit('focus', camera)"
|
|
9
10
|
@dblclick="camera && toggleFullscreen()"
|
|
10
11
|
@keydown.enter="camera && toggleFullscreen()"
|
|
@@ -178,10 +179,10 @@ import {
|
|
|
178
179
|
* origin's document is blocked by the browser — correctly.
|
|
179
180
|
*
|
|
180
181
|
* That ceiling has not moved. What has changed is that the badge no longer
|
|
181
|
-
* pretends otherwise. A loaded page earns **"Not
|
|
182
|
+
* pretends otherwise. A loaded page earns **"Not confirmed"**, not "Live"; only
|
|
182
183
|
* a frame the video service itself reports over `postMessage` earns "Live".
|
|
183
184
|
* The service sends nothing today, so every tile on this estate sits at "Not
|
|
184
|
-
*
|
|
185
|
+
* confirmed" — which is the true answer. It went in because on the live wall two
|
|
185
186
|
* cameras rendered a blank rectangle under a green "Live" badge, which is the
|
|
186
187
|
* one thing a monitoring screen must never do.
|
|
187
188
|
*
|
|
@@ -239,10 +240,13 @@ const healthLine = computed(() => {
|
|
|
239
240
|
return lastFrame && !props.dense ? `${label} · ${lastFrame}` : label;
|
|
240
241
|
});
|
|
241
242
|
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Everything the line had no room for, on hover. The firmware-and-clock
|
|
245
|
+
* sentence used to be appended here too; it is gone for the same reason it is
|
|
246
|
+
* gone from the panel — this screen never showed either value, so explaining
|
|
247
|
+
* their absence gave the reader nothing to do. See `THealthView`.
|
|
248
|
+
*/
|
|
249
|
+
const healthTitle = computed(() => recorder.value.detail || "");
|
|
246
250
|
|
|
247
251
|
/* -------------------------------------------------------------------- zoom */
|
|
248
252
|
|
|
@@ -432,7 +436,7 @@ function restartFrameClock() {
|
|
|
432
436
|
* direction.
|
|
433
437
|
*
|
|
434
438
|
* 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
|
|
439
|
+
* loaded is already at `page-up` and stays there. Sitting on "Not confirmed"
|
|
436
440
|
* indefinitely is deliberate — declaring a camera dead on a timer, with no
|
|
437
441
|
* evidence, is the same mistake as declaring it live on a timer.
|
|
438
442
|
*/
|
|
@@ -483,8 +487,40 @@ async function toggleFullscreen() {
|
|
|
483
487
|
min-height: 0;
|
|
484
488
|
}
|
|
485
489
|
|
|
490
|
+
/*
|
|
491
|
+
THE SELECTED TILE, AND WHY IT IS NOW LOUDER.
|
|
492
|
+
|
|
493
|
+
The panel under the wall describes ONE tile. It used to say which by printing
|
|
494
|
+
the camera's name — directly under a tile already captioned with that name —
|
|
495
|
+
because the ring here was too quiet to be doing that job: 2 px of #5b9be0
|
|
496
|
+
disappears against a bright picture, and at nine tiles it was a hairline the
|
|
497
|
+
eye skipped. So the panel was answering "which one?" in words, and the words
|
|
498
|
+
read as the name repeated twice.
|
|
499
|
+
|
|
500
|
+
Two marks now, and between them they hold at 1x1, 2x2 and 3x3:
|
|
501
|
+
|
|
502
|
+
- a **3 px accent ring**, with a 1 px scrim hairline INSIDE it. The hairline is
|
|
503
|
+
what makes the ring survive a white picture — accent-on-white is a weak edge,
|
|
504
|
+
accent-on-near-black is not, so the ring carries its own dark backing rather
|
|
505
|
+
than depending on what the camera happens to be pointing at.
|
|
506
|
+
- the **name chip underlined in the accent**, which is the same mark the
|
|
507
|
+
toolbar already uses for a selected tool (`.vms__tool--on`). At 3x3 the ring
|
|
508
|
+
is 3 px on a ~118 px tile; the underline sits right beside the caption a
|
|
509
|
+
supervisor is already reading, so identification does not depend on
|
|
510
|
+
perceiving 3 px from across a room.
|
|
511
|
+
|
|
512
|
+
INVENTED VALUES, recorded: the ring is 3 px where it was 2 px. Everything else
|
|
513
|
+
is existing — `--vms-accent`, `--vms-scrim-solid`, and the toolbar's 2 px
|
|
514
|
+
underline idiom. No new colour.
|
|
515
|
+
*/
|
|
486
516
|
.vms-tile--focused {
|
|
487
|
-
box-shadow:
|
|
517
|
+
box-shadow:
|
|
518
|
+
inset 0 0 0 3px var(--vms-accent, #5b9be0),
|
|
519
|
+
inset 0 0 0 4px var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.vms-tile--focused .vms-tile__name {
|
|
523
|
+
box-shadow: inset 0 -2px 0 var(--vms-accent, #5b9be0);
|
|
488
524
|
}
|
|
489
525
|
|
|
490
526
|
.vms-tile:focus-visible {
|
package/package.json
CHANGED
|
@@ -4,13 +4,16 @@ import { test } from "node:test";
|
|
|
4
4
|
import {
|
|
5
5
|
agoLabel,
|
|
6
6
|
capabilityView,
|
|
7
|
+
CAPABILITY_NEXT_STEPS,
|
|
7
8
|
clampPage,
|
|
8
9
|
clampPan,
|
|
9
10
|
clampZoom,
|
|
11
|
+
focusedCamera,
|
|
10
12
|
healthView,
|
|
11
13
|
isDenseLayout,
|
|
12
14
|
playerOrigin,
|
|
13
15
|
playerSignal,
|
|
16
|
+
sharedCapabilityNote,
|
|
14
17
|
tileView,
|
|
15
18
|
wallCameras,
|
|
16
19
|
wallLayout,
|
|
@@ -69,6 +72,46 @@ test("a selection sets the order, and a stale id is dropped rather than drawn",
|
|
|
69
72
|
assert.equal(tiles[0]._id, "b");
|
|
70
73
|
});
|
|
71
74
|
|
|
75
|
+
/* ------------------------------------------- which tile the panel is about */
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
THE PANEL STOPPED PRINTING THE CAMERA'S NAME, AND THESE ARE THE TWO FACTS
|
|
79
|
+
THAT LET IT.
|
|
80
|
+
|
|
81
|
+
The name was there so the reader could tell which of up to nine tiles the
|
|
82
|
+
panel described. It is now the selected tile's accent ring that answers that —
|
|
83
|
+
which is only safe while the panel can never be about a tile that is not
|
|
84
|
+
drawn. Five cameras across two pages is exactly the case the owner asked
|
|
85
|
+
about, so it is exactly the case pinned here.
|
|
86
|
+
*/
|
|
87
|
+
const FIVE = ["a", "b", "c", "d", "e"].map((id) => ip({ _id: id, name: id.toUpperCase() }));
|
|
88
|
+
|
|
89
|
+
test("nothing selected yet: the panel is about the first tile ON THIS PAGE", () => {
|
|
90
|
+
assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 0), "")?._id, "a");
|
|
91
|
+
// Page 2 of the same wall holds one camera, and the panel is about that one —
|
|
92
|
+
// not still about "a", which is no longer on screen.
|
|
93
|
+
assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 1), "")?._id, "e");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("paging away from your selection never leaves the panel describing an off-page camera", () => {
|
|
97
|
+
// Selected "a" on page 1, then paged to page 2. "a" is not drawn there.
|
|
98
|
+
const pageTwo = wallTiles(FIVE, [], "2x2", 1);
|
|
99
|
+
const shown = focusedCamera(pageTwo, "a");
|
|
100
|
+
assert.equal(shown?._id, "e");
|
|
101
|
+
assert.ok(
|
|
102
|
+
pageTwo.some((c) => c._id === shown?._id),
|
|
103
|
+
"the panel must always describe a tile on the current page"
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("paging back restores the selection, because the raw id is kept", () => {
|
|
108
|
+
assert.equal(focusedCamera(wallTiles(FIVE, [], "2x2", 0), "c")?._id, "c");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an empty wall has nothing to describe", () => {
|
|
112
|
+
assert.equal(focusedCamera([], "a"), null);
|
|
113
|
+
});
|
|
114
|
+
|
|
72
115
|
/* ---------------------------------------------------------------- rule 2/3 */
|
|
73
116
|
|
|
74
117
|
test("no descriptor at all counts as NOT available", () => {
|
|
@@ -131,7 +174,98 @@ test("a switched-off control says what would switch it on and who to ask", () =>
|
|
|
131
174
|
// The server's sentence is still verbatim — rule 3 is not traded away for rule 5.
|
|
132
175
|
assert.match(v.detail, /^No direct connection/);
|
|
133
176
|
assert.match(v.nextStep, /Seven365 administrator/);
|
|
134
|
-
assert.match(v.nextStep, /not
|
|
177
|
+
assert.match(v.nextStep, /not switchable from this page/i);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/*
|
|
181
|
+
THE ADVICE HAS TO NAME THE TWO CHANGES, AND IT HAS TO STAY OUT OF THE
|
|
182
|
+
DEPLOYMENT MANUAL.
|
|
183
|
+
|
|
184
|
+
"Ask your Seven365 administrator" was the whole of the old advice and it was a
|
|
185
|
+
dead end — worse than useless for the owner, who IS the administrator. What
|
|
186
|
+
makes the new line actionable is that it names the two real changes: the
|
|
187
|
+
network has to let the server reach the recorder, and the recorder then has to
|
|
188
|
+
be configured on the server. Both halves are pinned here, because dropping
|
|
189
|
+
either one silently returns the sentence to a shrug.
|
|
190
|
+
|
|
191
|
+
And the other direction is pinned too: no environment variable name may appear
|
|
192
|
+
in copy a property manager reads. The names are correct, they are in
|
|
193
|
+
`iservice365-core/docs/camera-integration-config.md`, and they are unreadable
|
|
194
|
+
to the person this screen is written for.
|
|
195
|
+
*/
|
|
196
|
+
test("the setup advice names the network change AND the server change", () => {
|
|
197
|
+
const advice = CAPABILITY_NEXT_STEPS["device-http-not-configured"];
|
|
198
|
+
assert.match(advice, /network/i);
|
|
199
|
+
assert.match(advice, /recorder/i);
|
|
200
|
+
assert.match(advice, /server's configuration/i);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("no environment variable name is ever shown to a reader", () => {
|
|
204
|
+
// Anything SCREAMING_SNAKE would be a deployment detail leaking into product
|
|
205
|
+
// copy. `CAMERA_DEVICE_HTTP` and friends belong in the deployment document.
|
|
206
|
+
for (const [reason, advice] of Object.entries(CAPABILITY_NEXT_STEPS)) {
|
|
207
|
+
assert.doesNotMatch(advice, /[A-Z][A-Z0-9]*_[A-Z0-9_]+/, `${reason} names a variable`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/* ---------------------------------------------------------------- rule 7 */
|
|
212
|
+
|
|
213
|
+
const OFF_FOR_THE_SAME_REASON = {
|
|
214
|
+
state: "unsupported",
|
|
215
|
+
reason: "device-http-not-configured",
|
|
216
|
+
detail: "No direct connection to this camera's recorder is configured on this server.",
|
|
217
|
+
};
|
|
218
|
+
const CAP_KEYS = WALL_CAPABILITY_CONTROLS.map((c) => c.key);
|
|
219
|
+
|
|
220
|
+
test("four controls off for one reason state that reason once", () => {
|
|
221
|
+
// What the owner actually saw: the same sentence and the same next step,
|
|
222
|
+
// four times in a row, under four different titles.
|
|
223
|
+
const cam = ip({
|
|
224
|
+
capabilities: {
|
|
225
|
+
// Exactly the live wall: zoom works, the other four are off together.
|
|
226
|
+
digitalZoom: { state: "supported" },
|
|
227
|
+
playback: OFF_FOR_THE_SAME_REASON,
|
|
228
|
+
events: OFF_FOR_THE_SAME_REASON,
|
|
229
|
+
ptz: OFF_FOR_THE_SAME_REASON,
|
|
230
|
+
presets: OFF_FOR_THE_SAME_REASON,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
const note = sharedCapabilityNote(cam, CAP_KEYS);
|
|
234
|
+
assert.match(note.detail, /^No direct connection/);
|
|
235
|
+
assert.match(note.nextStep, /Seven365 administrator/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("controls off for DIFFERENT reasons are never merged into one", () => {
|
|
239
|
+
// "The recorder is unreachable" and "this camera cannot move" are two
|
|
240
|
+
// problems with two different people to ask. Flattening them would be a lie
|
|
241
|
+
// of convenience, so the group note is refused and each card keeps its own.
|
|
242
|
+
const cam = ip({
|
|
243
|
+
capabilities: {
|
|
244
|
+
playback: OFF_FOR_THE_SAME_REASON,
|
|
245
|
+
ptz: { state: "unsupported", reason: "device-no-ptz", detail: "This camera cannot move." },
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("one control off on its own does not get a group note", () => {
|
|
252
|
+
const cam = ip({
|
|
253
|
+
capabilities: {
|
|
254
|
+
playback: OFF_FOR_THE_SAME_REASON,
|
|
255
|
+
events: { state: "supported" },
|
|
256
|
+
ptz: { state: "supported" },
|
|
257
|
+
presets: { state: "supported" },
|
|
258
|
+
digitalZoom: { state: "supported" },
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
assert.equal(sharedCapabilityNote(cam, CAP_KEYS), null);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("a camera with no descriptor at all still groups its unknowns", () => {
|
|
265
|
+
// Every control reads "Not checked yet" with the same not-probed sentence —
|
|
266
|
+
// which is four identical cards, the same noise, and the same fix.
|
|
267
|
+
const note = sharedCapabilityNote(ip(), CAP_KEYS);
|
|
268
|
+
assert.match(note.detail, /has not been asked/);
|
|
135
269
|
});
|
|
136
270
|
|
|
137
271
|
test("a reason code this build has never seen still names somebody to ask", () => {
|
|
@@ -175,15 +309,19 @@ test("a page that has not loaded is Connecting, never Live", () => {
|
|
|
175
309
|
* green "Live". The page had loaded; no picture was arriving. A loaded page is
|
|
176
310
|
* now worth exactly one thing, and it is not the word "Live".
|
|
177
311
|
*/
|
|
178
|
-
test("a loaded player page is NOT Live - it is Not
|
|
312
|
+
test("a loaded player page is NOT Live - it is Not confirmed", () => {
|
|
179
313
|
const v = tileView(ip(), { player: "page-up" });
|
|
180
314
|
assert.equal(v.state, "unverified");
|
|
181
|
-
assert.equal(v.label, "Not
|
|
315
|
+
assert.equal(v.label, "Not confirmed");
|
|
182
316
|
// No scrim: there may be a perfectly good picture under that badge, and
|
|
183
317
|
// covering a working camera would be a worse lie than the one being fixed.
|
|
184
318
|
assert.equal(v.detail, null);
|
|
185
319
|
assert.equal(v.showPlayer, true);
|
|
186
|
-
assert.match(v.note, /cannot confirm
|
|
320
|
+
assert.match(v.note, /cannot confirm the picture is arriving/);
|
|
321
|
+
// Plain English, and an instruction rather than an explanation: the reader is
|
|
322
|
+
// a guard, not a browser engineer. Nothing about origins, players or frames.
|
|
323
|
+
assert.match(v.note, /check the tile/i);
|
|
324
|
+
assert.doesNotMatch(v.note, /browser|origin|player|iframe|service/i);
|
|
187
325
|
});
|
|
188
326
|
|
|
189
327
|
test("only a reported frame earns the word Live", () => {
|
|
@@ -349,16 +487,44 @@ test("reachability alone is enough when the server sends no summary word", () =>
|
|
|
349
487
|
assert.equal(healthView({ reachable: false }).tone, "down");
|
|
350
488
|
});
|
|
351
489
|
|
|
352
|
-
test("the server's health
|
|
490
|
+
test("the server's health sentence is carried through, not rewritten", () => {
|
|
353
491
|
const v = healthView({
|
|
354
492
|
health: "unreachable",
|
|
355
493
|
reachable: false,
|
|
356
494
|
reason: "The recorder did not respond.",
|
|
495
|
+
});
|
|
496
|
+
assert.equal(v.detail, "The recorder did not respond.");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The firmware-and-clock sentence is the server explaining why two values it
|
|
501
|
+
* never showed are blank. It was drawn on the panel and appended to the tile's
|
|
502
|
+
* tooltip; a guard can do nothing with a firmware version, so it is not carried
|
|
503
|
+
* at all now. Pinned, because "soften it" was the tempting answer and the wrong
|
|
504
|
+
* one.
|
|
505
|
+
*/
|
|
506
|
+
test("the firmware and device-clock sentence is not carried to the screen", () => {
|
|
507
|
+
const v = healthView({
|
|
508
|
+
health: "ok",
|
|
509
|
+
reachable: true,
|
|
357
510
|
detailUnavailableReason:
|
|
358
511
|
"Firmware and device clock are not available: this recorder is reachable over video only.",
|
|
359
512
|
});
|
|
360
|
-
assert.equal(v.detail,
|
|
361
|
-
assert.
|
|
513
|
+
assert.equal(v.detail, null);
|
|
514
|
+
assert.equal("deviceDetail" in v, false);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The complaint that started this: a green "Recorder responding" beside "Last
|
|
519
|
+
* picture this server received: none yet" reads as a contradiction. It is not
|
|
520
|
+
* one — but the panel now simply says nothing when there has never been a
|
|
521
|
+
* picture, which is the only reading a guard can act on.
|
|
522
|
+
*/
|
|
523
|
+
test("a recorder that is up with no picture yet reports no picture line at all", () => {
|
|
524
|
+
const v = healthView({ health: "ok", reachable: true, lastFrameAt: null });
|
|
525
|
+
assert.equal(v.tone, "ok");
|
|
526
|
+
assert.equal(v.label, "Recorder responding");
|
|
527
|
+
assert.equal(v.lastFrame, null);
|
|
362
528
|
});
|
|
363
529
|
|
|
364
530
|
test("a picture's age is coarse above a minute, and never a fake time", () => {
|
package/utils/camera-wall.ts
CHANGED
|
@@ -196,6 +196,30 @@ export function wallTiles(
|
|
|
196
196
|
return chosen.slice(start, start + tiles);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* WHICH TILE THE DETAIL PANEL IS ABOUT, and the guarantee that makes the panel
|
|
201
|
+
* able to stop naming it.
|
|
202
|
+
*
|
|
203
|
+
* The panel used to print the camera's name so the reader could tell which of
|
|
204
|
+
* up to nine tiles it was describing. The name is gone — the selected tile wears
|
|
205
|
+
* an accent ring instead — and that only works because **this can never return a
|
|
206
|
+
* camera that is not on the page being looked at.** It resolves out of the
|
|
207
|
+
* CURRENT page's tiles, so a selection left behind on page 1 does not leave the
|
|
208
|
+
* panel describing an invisible camera from page 2; it falls back to the first
|
|
209
|
+
* tile of the page you are actually on, which the ring then marks.
|
|
210
|
+
*
|
|
211
|
+
* The caller keeps the raw id, so paging back restores the original selection.
|
|
212
|
+
*
|
|
213
|
+
* Extracted from the component purely so this guarantee is a tested rule rather
|
|
214
|
+
* than a line of template nobody would think to check.
|
|
215
|
+
*/
|
|
216
|
+
export function focusedCamera(
|
|
217
|
+
tiles: TWallCamera[],
|
|
218
|
+
focusedId: string | undefined
|
|
219
|
+
): TWallCamera | null {
|
|
220
|
+
return tiles.find((c) => c._id === focusedId) || tiles[0] || null;
|
|
221
|
+
}
|
|
222
|
+
|
|
199
223
|
/**
|
|
200
224
|
* How many grid rows this page actually needs — the other half of packing.
|
|
201
225
|
*
|
|
@@ -253,22 +277,49 @@ const NOT_PROBED = "This camera has not been asked what it can do yet.";
|
|
|
253
277
|
* **Every one of these says the same true and unwelcome thing in a different
|
|
254
278
|
* way: none of it is switched on from this page.** Saying so plainly is the
|
|
255
279
|
* point. An empty settings screen that quietly does nothing would be worse.
|
|
280
|
+
*
|
|
281
|
+
* ## "Ask your Seven365 administrator" was a dead end, and it has been replaced
|
|
282
|
+
*
|
|
283
|
+
* That was the whole of the advice, and the owner — who IS the administrator —
|
|
284
|
+
* read it on the live wall and pointed out that it tells him nothing. It tells a
|
|
285
|
+
* property manager nothing either: they raise a ticket and wait, because the
|
|
286
|
+
* screen never said what the ticket should ASK FOR.
|
|
287
|
+
*
|
|
288
|
+
* The real answer is two changes, and they are two different jobs for two
|
|
289
|
+
* different people:
|
|
290
|
+
*
|
|
291
|
+
* 1. **a network change** — the site's network has to let our server reach the
|
|
292
|
+
* camera recorder. Today it does not: the recorder answers video only.
|
|
293
|
+
* 2. **a configuration change on our server** — that recorder then has to be
|
|
294
|
+
* added to the server's configuration.
|
|
295
|
+
*
|
|
296
|
+
* So each line below names the change that is missing and who does it, in that
|
|
297
|
+
* order. **No environment variable is named**, deliberately: the names belong in
|
|
298
|
+
* the deployment documentation (`iservice365-core/docs/camera-integration-config.md`),
|
|
299
|
+
* not on a property manager's screen, and printing them would make the sentence
|
|
300
|
+
* unreadable to the person it is written for without helping the person who
|
|
301
|
+
* needs them, who has the document.
|
|
302
|
+
*
|
|
303
|
+
* Nor does any line point at the camera setup guide. That guide covers ADDING a
|
|
304
|
+
* camera in Site Settings; it does not cover recorder connectivity, so sending
|
|
305
|
+
* the reader there would be sending them somewhere that does not answer the
|
|
306
|
+
* question. Naming the two changes IS the actionable content.
|
|
256
307
|
*/
|
|
257
308
|
export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
|
|
258
309
|
"device-http-not-configured":
|
|
259
|
-
"
|
|
310
|
+
"Not switchable from this page. It needs the site's network opened so our server can reach the camera recorder, and that recorder then added to the server's configuration — your Seven365 administrator arranges both with whoever installed the cameras.",
|
|
260
311
|
"device-http-disabled":
|
|
261
|
-
"
|
|
312
|
+
"Not switchable from this page. The connection to the recorder exists but is turned off in the server's configuration — your Seven365 administrator turns it on.",
|
|
262
313
|
"device-http-unreachable":
|
|
263
|
-
"
|
|
314
|
+
"Our server cannot reach this camera's recorder. Usually the site's network is blocking it or the recorder is down — your Seven365 administrator and whoever installed the cameras check it between them.",
|
|
264
315
|
"device-http-locked-out":
|
|
265
|
-
"Sign-ins to the recorder are being held back so its account is not locked out.
|
|
316
|
+
"Sign-ins to the recorder are being held back so its account is not locked out. It clears on its own; if it keeps happening the password held for the recorder is wrong and your Seven365 administrator corrects it in the server's configuration.",
|
|
266
317
|
"control-not-enabled":
|
|
267
|
-
"
|
|
318
|
+
"Not switchable from this page. This one moves real hardware, so a Seven365 backend lead has to approve it before it is turned on in the server's configuration.",
|
|
268
319
|
"device-no-ptz":
|
|
269
320
|
"This camera cannot move, so there is nothing to switch on. A different camera would be needed.",
|
|
270
321
|
"no-recorder-configured":
|
|
271
|
-
"
|
|
322
|
+
"Not switchable from this page. This camera's video service has no recorder linked to it in the server's configuration — your Seven365 administrator adds it.",
|
|
272
323
|
"no-channel-in-address":
|
|
273
324
|
"This camera's address does not say which stream it is. Correct it in Site Settings, under CCTV.",
|
|
274
325
|
"not-a-relay-player-url":
|
|
@@ -282,7 +333,7 @@ export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
|
|
|
282
333
|
"device-not-probed":
|
|
283
334
|
"The camera is asked what it can do the first time it is opened. Nothing to do — check back in a moment.",
|
|
284
335
|
"no-transport":
|
|
285
|
-
"Nothing on this server can do
|
|
336
|
+
"Not built yet. Nothing on this server can do it.",
|
|
286
337
|
};
|
|
287
338
|
|
|
288
339
|
/**
|
|
@@ -292,7 +343,7 @@ export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
|
|
|
292
343
|
* setting on this page, and there is still somebody to ask.
|
|
293
344
|
*/
|
|
294
345
|
const NEXT_STEP_FALLBACK =
|
|
295
|
-
"
|
|
346
|
+
"Not switchable from this page — it needs a change to the server's configuration, which your Seven365 administrator makes.";
|
|
296
347
|
|
|
297
348
|
/**
|
|
298
349
|
* The capabilities the wall draws a control for, and their plain names.
|
|
@@ -386,6 +437,47 @@ export function capabilityView(
|
|
|
386
437
|
};
|
|
387
438
|
}
|
|
388
439
|
|
|
440
|
+
/**
|
|
441
|
+
* RULE 7 — SAY IT ONCE FOR THE GROUP WHEN IT IS ONE FACT, NOT FOUR.
|
|
442
|
+
*
|
|
443
|
+
* On this estate all four of the switched-off controls are off for the SAME
|
|
444
|
+
* reason: there is no direct connection to the recorder. So the panel drew four
|
|
445
|
+
* cards, each carrying an identical sentence and an identical next step —
|
|
446
|
+
* eight lines of repetition saying one thing. Four copies of a sentence do not
|
|
447
|
+
* make it four facts; they make the reader skim, and skimming is how the one
|
|
448
|
+
* line that mattered got missed.
|
|
449
|
+
*
|
|
450
|
+
* So when every unavailable control shares the same reason, this returns it and
|
|
451
|
+
* the panel prints it once above the row. Each card keeps its own title and its
|
|
452
|
+
* own tag, so the individual state is never lost — only the echo is.
|
|
453
|
+
*
|
|
454
|
+
* Returns `null` the moment the reasons DIFFER, which is the case that must not
|
|
455
|
+
* be flattened: "the recorder is unreachable" and "this camera cannot move" are
|
|
456
|
+
* two different problems with two different people to ask, and merging them
|
|
457
|
+
* would be a lie of convenience. Also `null` when nothing is off (nothing to
|
|
458
|
+
* explain) or when only one control is off (a group note for a group of one is
|
|
459
|
+
* just the card, moved).
|
|
460
|
+
*/
|
|
461
|
+
export function sharedCapabilityNote(
|
|
462
|
+
camera: TWallCamera | null | undefined,
|
|
463
|
+
keys: readonly string[]
|
|
464
|
+
): { detail: string; nextStep: string | null } | null {
|
|
465
|
+
const off = keys
|
|
466
|
+
.map((key) => capabilityView(camera, key))
|
|
467
|
+
.filter((view) => !view.available);
|
|
468
|
+
|
|
469
|
+
if (off.length < 2) return null;
|
|
470
|
+
|
|
471
|
+
const first = off[0];
|
|
472
|
+
const same = off.every(
|
|
473
|
+
(view) => view.detail === first.detail && view.nextStep === first.nextStep
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return same && first.detail
|
|
477
|
+
? { detail: first.detail, nextStep: first.nextStep }
|
|
478
|
+
: null;
|
|
479
|
+
}
|
|
480
|
+
|
|
389
481
|
/* -------------------------------------------------------------------------- */
|
|
390
482
|
/* Tile state */
|
|
391
483
|
/* -------------------------------------------------------------------------- */
|
|
@@ -428,10 +520,16 @@ export type TTileState =
|
|
|
428
520
|
*
|
|
429
521
|
* `unverified` is the state this wall is in for every camera on this estate
|
|
430
522
|
* today, and saying so is the point — see the note above `tileView`.
|
|
523
|
+
*
|
|
524
|
+
* It used to read "Not verified", which is a word from our side of the fence:
|
|
525
|
+
* it describes a check we did not manage to make. "Not confirmed" describes the
|
|
526
|
+
* PICTURE, which is the only thing the reader cares about, and it is the same
|
|
527
|
+
* claim. The instruction that goes with it — check the tile — is in
|
|
528
|
+
* `UNVERIFIED_DETAIL`, on the badge's tooltip and once on the panel.
|
|
431
529
|
*/
|
|
432
530
|
export const TILE_LABELS = {
|
|
433
531
|
live: "Live",
|
|
434
|
-
unverified: "Not
|
|
532
|
+
unverified: "Not confirmed",
|
|
435
533
|
connecting: "Connecting…",
|
|
436
534
|
"no-signal": "No signal",
|
|
437
535
|
offline: "No connection",
|
|
@@ -548,12 +646,23 @@ export type TTileView = {
|
|
|
548
646
|
};
|
|
549
647
|
|
|
550
648
|
/**
|
|
551
|
-
* The sentence behind "Not
|
|
552
|
-
* rather than nine times across the wall.
|
|
553
|
-
*
|
|
649
|
+
* The sentence behind "Not confirmed", said once on the focused camera's panel
|
|
650
|
+
* rather than nine times across the wall.
|
|
651
|
+
*
|
|
652
|
+
* **Written for a guard or a property manager, not for us.** The first version
|
|
653
|
+
* of this line explained cross-origin isolation — "the player belongs to the
|
|
654
|
+
* video service and a browser cannot look inside it" — which is true, is the
|
|
655
|
+
* reason, and is none of the reader's business. The owner read it on the live
|
|
656
|
+
* wall and said so. A reader of this screen has exactly three questions: is
|
|
657
|
+
* this camera working, what do I do if it is not, and what can I do here. WHY
|
|
658
|
+
* the software cannot answer the first one is our problem, not theirs.
|
|
659
|
+
*
|
|
660
|
+
* So: what we cannot do, then what they should do. Nothing else. The claim is
|
|
661
|
+
* every bit as honest as the paragraph it replaces — it just stops making the
|
|
662
|
+
* reader sit through an engineering lesson to get to the instruction.
|
|
554
663
|
*/
|
|
555
664
|
export const UNVERIFIED_DETAIL =
|
|
556
|
-
"
|
|
665
|
+
"This page cannot confirm the picture is arriving, so check the tile yourself. If it is blank or frozen, report the camera.";
|
|
557
666
|
|
|
558
667
|
/**
|
|
559
668
|
* Rule 4, plus every reason a tile may have nothing to show.
|
|
@@ -632,14 +741,14 @@ export function tileView(
|
|
|
632
741
|
// we genuinely cannot tell a dead camera from a dead video service.
|
|
633
742
|
return showing(
|
|
634
743
|
"no-signal",
|
|
635
|
-
"
|
|
744
|
+
"No picture. The camera or the video service may be down. Report it if it stays like this."
|
|
636
745
|
);
|
|
637
746
|
case "stalled":
|
|
638
747
|
// It WAS playing and stopped. That is the one case where we can say a
|
|
639
748
|
// picture has genuinely gone away, because we saw one arrive first.
|
|
640
749
|
return showing(
|
|
641
750
|
"no-signal",
|
|
642
|
-
"The picture stopped arriving. The camera or the video service may have dropped."
|
|
751
|
+
"The picture stopped arriving. The camera or the video service may have dropped. Report it if it does not come back."
|
|
643
752
|
);
|
|
644
753
|
case "playing":
|
|
645
754
|
return showing("live", null);
|
|
@@ -688,15 +797,27 @@ export type THealthView = {
|
|
|
688
797
|
detail: string | null;
|
|
689
798
|
/** "4 min ago", or `null` when this server has never had a picture. */
|
|
690
799
|
lastFrame: string | null;
|
|
691
|
-
/** Why firmware and clock are blank. The server's sentence, verbatim. */
|
|
692
|
-
deviceDetail: string | null;
|
|
693
800
|
};
|
|
694
801
|
|
|
802
|
+
/*
|
|
803
|
+
THE FIRMWARE-AND-CLOCK LINE IS GONE, AND IT IS NOT COMING BACK.
|
|
804
|
+
|
|
805
|
+
The server sends `detailUnavailableReason` — "Firmware and device clock are
|
|
806
|
+
not available: this recorder is reachable over video only." — so that two
|
|
807
|
+
blank fields do not read as "checked, and fine". That is a good reason to
|
|
808
|
+
send it and a bad reason to DRAW it: this screen never showed firmware or a
|
|
809
|
+
device clock in the first place, so the sentence was explaining the absence
|
|
810
|
+
of something the reader had not been offered and could not use. A guard
|
|
811
|
+
cannot act on a firmware version. Softening the wording would have kept the
|
|
812
|
+
noise, so the line is removed instead. The field is still on
|
|
813
|
+
`TWallCameraHealth` because the API sends it; nothing on the wall draws it.
|
|
814
|
+
*/
|
|
815
|
+
|
|
695
816
|
const HEALTH_LABELS: Record<string, { tone: THealthTone; label: string }> = {
|
|
696
817
|
ok: { tone: "ok", label: "Recorder responding" },
|
|
697
818
|
drifted: { tone: "warn", label: "Recorder clock is out" },
|
|
698
819
|
unreachable: { tone: "down", label: "Recorder not responding" },
|
|
699
|
-
unsupported: { tone: "unknown", label: "
|
|
820
|
+
unsupported: { tone: "unknown", label: "Recorder not checked" },
|
|
700
821
|
};
|
|
701
822
|
|
|
702
823
|
/**
|
|
@@ -716,7 +837,7 @@ export function healthView(
|
|
|
716
837
|
? HEALTH_LABELS.ok
|
|
717
838
|
: health?.reachable === false
|
|
718
839
|
? HEALTH_LABELS.unreachable
|
|
719
|
-
: { tone: "unknown", label: "
|
|
840
|
+
: { tone: "unknown", label: "Recorder not checked yet" };
|
|
720
841
|
|
|
721
842
|
const { tone, label } = known ?? fallback;
|
|
722
843
|
|
|
@@ -725,7 +846,6 @@ export function healthView(
|
|
|
725
846
|
label,
|
|
726
847
|
detail: (health?.reason || "").trim() || null,
|
|
727
848
|
lastFrame: agoLabel(health?.lastFrameAt, now),
|
|
728
|
-
deviceDetail: (health?.detailUnavailableReason || "").trim() || null,
|
|
729
849
|
};
|
|
730
850
|
}
|
|
731
851
|
|