@7365admin1/layer-common 3.2.4 → 3.2.6
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/CHANGELOG.md +204 -0
- package/components/BuildingUnitFormAdd.vue +5 -1
- package/components/CameraWall.vue +1034 -0
- package/components/CameraWallTile.vue +818 -0
- package/components/QrTemplate/CreditCardLandscape.vue +19 -12
- package/components/QrTemplate/PrintDialog.vue +209 -196
- package/components/VisitorManagement.vue +1 -1
- package/composables/useLocalAuth.ts +9 -36
- package/composables/useOrg.ts +1 -1
- package/composables/useSiteSettings.ts +50 -0
- package/package.json +2 -1
- package/types/org.d.ts +2 -0
- package/utils/camera-wall.test.ts +571 -0
- package/utils/camera-wall.ts +926 -0
- package/middleware/member.ts +0 -4
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CAMERA WALL'S RULES, WITH NO MARKUP IN THEM.
|
|
3
|
+
*
|
|
4
|
+
* `CameraWall.vue` draws the wall; this file decides what it is allowed to draw.
|
|
5
|
+
* Keeping the two apart is not tidiness — every rule below is one somebody can
|
|
6
|
+
* get wrong in a way nobody notices until a supervisor is looking at the wrong
|
|
7
|
+
* picture, so each one is stated once, here, and pinned by `camera-wall.test.ts`.
|
|
8
|
+
*
|
|
9
|
+
* ## The four rules
|
|
10
|
+
*
|
|
11
|
+
* 1. **Only `type: "ip"` cameras go on a wall.** An ANPR unit belongs to visitor
|
|
12
|
+
* and vehicle management: it is pointed at a number plate, it is wired to a
|
|
13
|
+
* barrier, and its record carries a real device address and a real credential.
|
|
14
|
+
* Putting one on a monitoring wall is a defect, not a feature. The request
|
|
15
|
+
* already asks the server for `type=ip`; `wallCameras()` filters again on the
|
|
16
|
+
* way in, because "the caller passed the right query" is an assumption and
|
|
17
|
+
* this is the rule.
|
|
18
|
+
* 2. **A capability we have not been told about is NOT available.** An absent or
|
|
19
|
+
* malformed descriptor reads as `unknown`, and `unknown` counts as no — so a
|
|
20
|
+
* browser talking to an older API cannot draw a control that would do nothing.
|
|
21
|
+
* 3. **A control that is off is drawn, dimmed, and says why** — never hidden. The
|
|
22
|
+
* sentence shown is the SERVER'S own sentence, never rewritten here, so a
|
|
23
|
+
* camera cannot explain itself two different ways on two screens.
|
|
24
|
+
* 4. **"Live" means a picture is arriving**, not "we asked for one", and not
|
|
25
|
+
* "the page that would show one loaded". Those are three different claims
|
|
26
|
+
* and the tile used to make the strongest one on the evidence for the
|
|
27
|
+
* weakest. See the long note above `tileView` — this rule was being broken
|
|
28
|
+
* in production and a supervisor was being told a blank tile was live.
|
|
29
|
+
* 5. **A switched-off control also says what would switch it on, and who to
|
|
30
|
+
* ask.** The server's sentence states the fact; it deliberately does not name
|
|
31
|
+
* a next step, because a server has no idea who the reader can go and talk
|
|
32
|
+
* to. So the next step is a SECOND line, owned here and keyed by the
|
|
33
|
+
* server's own reason CODE — rule 3 is intact, nothing is rewritten.
|
|
34
|
+
* 6. **The recorder's health and the player page's state are two different
|
|
35
|
+
* facts and never share one indicator.** "The page is up" and "the recorder
|
|
36
|
+
* answered" can disagree, and a supervisor needs to know which they are
|
|
37
|
+
* looking at. Merging them is the same defect as a still tile saying "Live".
|
|
38
|
+
*
|
|
39
|
+
* These match the iSecure365 mobile monitoring build one for one, deliberately:
|
|
40
|
+
* the same product should not answer the same question differently on a phone
|
|
41
|
+
* and on a laptop.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** One capability's answer, as the API returns it (core's `CameraCapabilityEntry`). */
|
|
45
|
+
export type TWallCapability = {
|
|
46
|
+
state?: "supported" | "unsupported" | "unknown";
|
|
47
|
+
transport?: string | null;
|
|
48
|
+
reason?: string | null;
|
|
49
|
+
/** The server's own sentence. Displayed verbatim; never rewritten here. */
|
|
50
|
+
detail?: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** A camera as the wall needs it. Extra fields on the record are ignored. */
|
|
54
|
+
export type TWallCamera = {
|
|
55
|
+
_id: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
/**
|
|
58
|
+
* The stored address. For a `type: "ip"` camera this is a complete, directly
|
|
59
|
+
* renderable live-video page — it is the picture, not a fragment to resolve.
|
|
60
|
+
*/
|
|
61
|
+
host?: string;
|
|
62
|
+
type?: string;
|
|
63
|
+
status?: string;
|
|
64
|
+
capabilities?: Record<string, TWallCapability>;
|
|
65
|
+
/**
|
|
66
|
+
* The server's own sentence for why this camera cannot show a picture, or
|
|
67
|
+
* `null`/absent when it can. Answered from the record before anything is
|
|
68
|
+
* requested, so it costs no device traffic.
|
|
69
|
+
*
|
|
70
|
+
* **Preferred over anything worked out here.** The server knows things the
|
|
71
|
+
* browser cannot — that no recorder is configured for this camera's relay, for
|
|
72
|
+
* instance, which is the true answer for every camera until the API host is
|
|
73
|
+
* configured. Replacing it with our own wording hides that.
|
|
74
|
+
*/
|
|
75
|
+
unavailableReason?: string | null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/* -------------------------------------------------------------------------- */
|
|
79
|
+
/* Layouts */
|
|
80
|
+
/* -------------------------------------------------------------------------- */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The three wall sizes.
|
|
84
|
+
*
|
|
85
|
+
* Square only, and only three of them. A VMS layout picker exists so a
|
|
86
|
+
* supervisor can change what they are watching in one click; a list of eleven
|
|
87
|
+
* arrangements is a settings screen wearing a toolbar's clothes.
|
|
88
|
+
*/
|
|
89
|
+
export const WALL_LAYOUTS = [
|
|
90
|
+
{ key: "1x1", columns: 1, tiles: 1, label: "Single" },
|
|
91
|
+
{ key: "2x2", columns: 2, tiles: 4, label: "Four" },
|
|
92
|
+
{ key: "3x3", columns: 3, tiles: 9, label: "Nine" },
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
export type TWallLayoutKey = (typeof WALL_LAYOUTS)[number]["key"];
|
|
96
|
+
|
|
97
|
+
/** Four tiles: enough to be a wall, small enough to still be readable. */
|
|
98
|
+
export const DEFAULT_WALL_LAYOUT: TWallLayoutKey = "2x2";
|
|
99
|
+
|
|
100
|
+
export function wallLayout(key: string | undefined) {
|
|
101
|
+
return WALL_LAYOUTS.find((l) => l.key === key) ?? WALL_LAYOUTS[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* At three columns a tile is small enough that the chrome starts eating the
|
|
106
|
+
* picture, so the type shrinks and the recorder line loses its timestamp.
|
|
107
|
+
*
|
|
108
|
+
* **It no longer removes the camera's name or its status.** It used to drop the
|
|
109
|
+
* status WORD at nine tiles and leave a bare 7 px dot — a mobile-first call
|
|
110
|
+
* that is wrong on a desktop wall, which is the layout a supervisor watches
|
|
111
|
+
* from furthest away and the one where a camera is most likely to be missed.
|
|
112
|
+
* Whether a camera is alive is the tile's whole job; it is not the thing that
|
|
113
|
+
* gets cut for room.
|
|
114
|
+
*/
|
|
115
|
+
export function isDenseLayout(key: string | undefined) {
|
|
116
|
+
return wallLayout(key).columns >= 3;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* -------------------------------------------------------------------------- */
|
|
120
|
+
/* Which cameras, and in what order */
|
|
121
|
+
/* -------------------------------------------------------------------------- */
|
|
122
|
+
|
|
123
|
+
/** Rule 1. Only `type: "ip"`. See the note at the top of this file. */
|
|
124
|
+
export function wallCameras(items: unknown): TWallCamera[] {
|
|
125
|
+
if (!Array.isArray(items)) return [];
|
|
126
|
+
return items.filter(
|
|
127
|
+
(c): c is TWallCamera => !!c && typeof c === "object" && (c as TWallCamera).type === "ip"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The cameras a wall would show, before the layout cuts them down.
|
|
133
|
+
*
|
|
134
|
+
* An empty selection is **not** "no cameras" — it is "nobody has chosen yet",
|
|
135
|
+
* and the wall falls back to the site's own order. A supervisor who clears the
|
|
136
|
+
* picker gets their site back, not a black screen.
|
|
137
|
+
*/
|
|
138
|
+
function wallChosen(
|
|
139
|
+
cameras: TWallCamera[],
|
|
140
|
+
selectedIds: string[] | undefined
|
|
141
|
+
): TWallCamera[] {
|
|
142
|
+
if (!selectedIds || !selectedIds.length) return cameras;
|
|
143
|
+
const byId = new Map(cameras.map((c) => [c._id, c]));
|
|
144
|
+
return selectedIds.map((id) => byId.get(id)).filter((c): c is TWallCamera => !!c);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How many pages the wall needs — never fewer than one.
|
|
149
|
+
*
|
|
150
|
+
* **This exists because it was missing, and a camera was going missing with
|
|
151
|
+
* it.** The wall took the first `tiles` cameras and dropped the rest on the
|
|
152
|
+
* floor: a site with five cameras on a 2x2 wall showed four, said "Cameras 5"
|
|
153
|
+
* in the toolbar, and gave nobody any way to reach the fifth. It did not read
|
|
154
|
+
* as a missing feature, it read as a broken count. The mobile wall has paged
|
|
155
|
+
* since it was built; this is the same rule, on the web.
|
|
156
|
+
*/
|
|
157
|
+
export function wallPageCount(
|
|
158
|
+
cameras: TWallCamera[],
|
|
159
|
+
selectedIds: string[] | undefined,
|
|
160
|
+
layoutKey: string | undefined
|
|
161
|
+
): number {
|
|
162
|
+
const total = wallChosen(cameras, selectedIds).length;
|
|
163
|
+
return Math.max(1, Math.ceil(total / wallLayout(layoutKey).tiles));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Keeps a page number inside the wall, whatever the layout just did to it. */
|
|
167
|
+
export function clampPage(page: number, pages: number): number {
|
|
168
|
+
if (!Number.isFinite(page)) return 0;
|
|
169
|
+
return Math.min(Math.max(0, Math.floor(page)), Math.max(0, pages - 1));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The cameras on one page, in order. **Never padded.**
|
|
174
|
+
*
|
|
175
|
+
* It used to pad to the layout's tile count with `null`, and the wall drew each
|
|
176
|
+
* one as a dashed box reading "No camera in this position." Five cameras on a
|
|
177
|
+
* nine-tile wall meant four dead grey rectangles, which is not what a video
|
|
178
|
+
* wall does and not information: nobody looking at that screen was unaware
|
|
179
|
+
* they had five cameras, and there is nothing to drop into an empty cell
|
|
180
|
+
* because cameras are added in Site Settings, not here.
|
|
181
|
+
*
|
|
182
|
+
* So the grid packs instead — see `wallRows`. The layout picker still means
|
|
183
|
+
* "at most this many, at this size"; it stops meaning "always exactly this
|
|
184
|
+
* many holes".
|
|
185
|
+
*/
|
|
186
|
+
export function wallTiles(
|
|
187
|
+
cameras: TWallCamera[],
|
|
188
|
+
selectedIds: string[] | undefined,
|
|
189
|
+
layoutKey: string | undefined,
|
|
190
|
+
page = 0
|
|
191
|
+
): TWallCamera[] {
|
|
192
|
+
const { tiles } = wallLayout(layoutKey);
|
|
193
|
+
const chosen = wallChosen(cameras, selectedIds);
|
|
194
|
+
const start = clampPage(page, wallPageCount(cameras, selectedIds, layoutKey)) * tiles;
|
|
195
|
+
|
|
196
|
+
return chosen.slice(start, start + tiles);
|
|
197
|
+
}
|
|
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
|
+
|
|
223
|
+
/**
|
|
224
|
+
* How many grid rows this page actually needs — the other half of packing.
|
|
225
|
+
*
|
|
226
|
+
* Without it a 3x3 wall holding five cameras still reserves three rows, so the
|
|
227
|
+
* five tiles are drawn two-thirds height with a band of empty screen under
|
|
228
|
+
* them. Sizing the grid to the content gives that screen back to the pictures,
|
|
229
|
+
* which is the entire reason a supervisor chose a bigger layout.
|
|
230
|
+
*
|
|
231
|
+
* Never more rows than the layout would have had, and never fewer than one.
|
|
232
|
+
*/
|
|
233
|
+
export function wallRows(count: number, layoutKey: string | undefined): number {
|
|
234
|
+
const { columns, tiles } = wallLayout(layoutKey);
|
|
235
|
+
const capped = Math.min(Math.max(0, Math.floor(count)), tiles);
|
|
236
|
+
return Math.min(Math.ceil(tiles / columns), Math.max(1, Math.ceil(capped / columns)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* -------------------------------------------------------------------------- */
|
|
240
|
+
/* Capabilities */
|
|
241
|
+
/* -------------------------------------------------------------------------- */
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The two words a switched-off control wears.
|
|
245
|
+
*
|
|
246
|
+
* Lifted unchanged from the mobile build, and the "yet" is the whole point: it
|
|
247
|
+
* is the difference between a feature that is broken and a feature that is
|
|
248
|
+
* waiting on something somebody has to switch on.
|
|
249
|
+
*/
|
|
250
|
+
export const CAPABILITY_TAGS = {
|
|
251
|
+
supported: "Ready",
|
|
252
|
+
unsupported: "Not set up yet",
|
|
253
|
+
unknown: "Not checked yet",
|
|
254
|
+
} as const;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* What a client with no descriptor at all should say. This is the server's own
|
|
258
|
+
* `device-not-probed` sentence, reused rather than reinvented, so an older API
|
|
259
|
+
* that sends no descriptor still explains itself in the product's own words.
|
|
260
|
+
*/
|
|
261
|
+
const NOT_PROBED = "This camera has not been asked what it can do yet.";
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* RULE 5 — the second line, and why the server cannot write it.
|
|
265
|
+
*
|
|
266
|
+
* The server's sentence is a statement of fact: *"No direct connection to this
|
|
267
|
+
* camera's recorder is configured on this server."* That is correct, it is the
|
|
268
|
+
* server's to own, and it is **not** an answer to the only question a reader
|
|
269
|
+
* actually has, which is "so how do I set it up?" — a real question, asked of
|
|
270
|
+
* this exact screen. A server cannot answer it: it knows the configuration is
|
|
271
|
+
* absent, it has no idea who the person reading has to go and ask.
|
|
272
|
+
*
|
|
273
|
+
* So the next step lives here, keyed by the server's own reason CODE rather
|
|
274
|
+
* than by matching its words. Rule 3 is untouched — the server's sentence is
|
|
275
|
+
* still shown verbatim; this is a second line under it.
|
|
276
|
+
*
|
|
277
|
+
* **Every one of these says the same true and unwelcome thing in a different
|
|
278
|
+
* way: none of it is switched on from this page.** Saying so plainly is the
|
|
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.
|
|
307
|
+
*/
|
|
308
|
+
export const CAPABILITY_NEXT_STEPS: Record<string, string> = {
|
|
309
|
+
"device-http-not-configured":
|
|
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.",
|
|
311
|
+
"device-http-disabled":
|
|
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.",
|
|
313
|
+
"device-http-unreachable":
|
|
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.",
|
|
315
|
+
"device-http-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.",
|
|
317
|
+
"control-not-enabled":
|
|
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.",
|
|
319
|
+
"device-no-ptz":
|
|
320
|
+
"This camera cannot move, so there is nothing to switch on. A different camera would be needed.",
|
|
321
|
+
"no-recorder-configured":
|
|
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.",
|
|
323
|
+
"no-channel-in-address":
|
|
324
|
+
"This camera's address does not say which stream it is. Correct it in Site Settings, under CCTV.",
|
|
325
|
+
"not-a-relay-player-url":
|
|
326
|
+
"This camera's address is not a live-video page address. Correct it in Site Settings, under CCTV.",
|
|
327
|
+
"no-address":
|
|
328
|
+
"This camera has no address. Add it in Site Settings, under CCTV.",
|
|
329
|
+
"invalid-address":
|
|
330
|
+
"This camera's address is not valid. Correct it in Site Settings, under CCTV.",
|
|
331
|
+
"camera-inactive":
|
|
332
|
+
"This camera is switched off. Set it back to active in Site Settings, under CCTV.",
|
|
333
|
+
"device-not-probed":
|
|
334
|
+
"The camera is asked what it can do the first time it is opened. Nothing to do — check back in a moment.",
|
|
335
|
+
"no-transport":
|
|
336
|
+
"Not built yet. Nothing on this server can do it.",
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* For a reason code this build has never heard of — a newer server, a code
|
|
341
|
+
* added after this layer was published. Deliberately vague about the cause and
|
|
342
|
+
* exact about the next step, which is the half that matters: it is still not a
|
|
343
|
+
* setting on this page, and there is still somebody to ask.
|
|
344
|
+
*/
|
|
345
|
+
const NEXT_STEP_FALLBACK =
|
|
346
|
+
"Not switchable from this page — it needs a change to the server's configuration, which your Seven365 administrator makes.";
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The capabilities the wall draws a control for, and their plain names.
|
|
350
|
+
*
|
|
351
|
+
* `ready` is what the panel says when the server reports the capability
|
|
352
|
+
* WORKING, and it describes **this screen** rather than the product. Only
|
|
353
|
+
* `digitalZoom` is actually built here; the other four are real on the camera
|
|
354
|
+
* and not yet drawn on the web, and saying so is better than a panel that
|
|
355
|
+
* points at a timeline nobody has built. Today the server reports all four
|
|
356
|
+
* unsupported, so none of those sentences is on screen — they are there so
|
|
357
|
+
* that the day one flips, the screen does not start lying.
|
|
358
|
+
*/
|
|
359
|
+
export const WALL_CAPABILITY_CONTROLS = [
|
|
360
|
+
{
|
|
361
|
+
key: "digitalZoom",
|
|
362
|
+
title: "Zoom",
|
|
363
|
+
ready:
|
|
364
|
+
"Scroll or pinch on a tile to zoom in, then drag to move around. Reset returns to the whole picture. This crops and enlarges the picture on your screen — it does not move the camera.",
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
key: "playback",
|
|
368
|
+
title: "Recorded footage",
|
|
369
|
+
ready: "This camera can serve recorded footage. The playback controls are not on this screen yet.",
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
key: "events",
|
|
373
|
+
title: "Events and alarms",
|
|
374
|
+
ready: "This camera reports events. The event list is not on this screen yet.",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
key: "ptz",
|
|
378
|
+
title: "Move camera",
|
|
379
|
+
ready: "This camera can be moved. The movement controls are not on this screen yet.",
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
key: "presets",
|
|
383
|
+
title: "Stored positions",
|
|
384
|
+
ready: "This camera has stored positions. Recalling them is not on this screen yet.",
|
|
385
|
+
},
|
|
386
|
+
] as const;
|
|
387
|
+
|
|
388
|
+
export type TCapabilityView = {
|
|
389
|
+
state: "supported" | "unsupported" | "unknown";
|
|
390
|
+
available: boolean;
|
|
391
|
+
/** Always set: "Ready" when it works, the reason wording when it does not. */
|
|
392
|
+
tag: string;
|
|
393
|
+
/**
|
|
394
|
+
* The server's sentence, or the not-probed one — and `null` when the
|
|
395
|
+
* capability WORKS, because the server sends no sentence for a working
|
|
396
|
+
* capability and falling back to the not-probed one made a working control
|
|
397
|
+
* claim it had never been checked.
|
|
398
|
+
*/
|
|
399
|
+
detail: string | null;
|
|
400
|
+
/** Rule 5: what would switch it on and who to ask. `null` when it works. */
|
|
401
|
+
nextStep: string | null;
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
/** Rules 2, 3 and 5, in one place. */
|
|
405
|
+
export function capabilityView(
|
|
406
|
+
camera: TWallCamera | null | undefined,
|
|
407
|
+
key: string
|
|
408
|
+
): TCapabilityView {
|
|
409
|
+
const entry = camera?.capabilities?.[key];
|
|
410
|
+
const state =
|
|
411
|
+
entry?.state === "supported" || entry?.state === "unsupported"
|
|
412
|
+
? entry.state
|
|
413
|
+
: "unknown";
|
|
414
|
+
|
|
415
|
+
if (state === "supported") {
|
|
416
|
+
return {
|
|
417
|
+
state,
|
|
418
|
+
available: true,
|
|
419
|
+
tag: CAPABILITY_TAGS.supported,
|
|
420
|
+
detail: null,
|
|
421
|
+
nextStep: null,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const reason = (entry?.reason || "").trim();
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
state,
|
|
429
|
+
available: false,
|
|
430
|
+
tag: CAPABILITY_TAGS[state],
|
|
431
|
+
detail: (entry?.detail || "").trim() || NOT_PROBED,
|
|
432
|
+
nextStep:
|
|
433
|
+
CAPABILITY_NEXT_STEPS[reason] ??
|
|
434
|
+
(state === "unknown"
|
|
435
|
+
? CAPABILITY_NEXT_STEPS["device-not-probed"]
|
|
436
|
+
: NEXT_STEP_FALLBACK),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
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
|
+
|
|
481
|
+
/* -------------------------------------------------------------------------- */
|
|
482
|
+
/* Tile state */
|
|
483
|
+
/* -------------------------------------------------------------------------- */
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* How long a player page may take to load before the tile stops claiming it is
|
|
487
|
+
* coming. Deliberately generous — lifted from the mobile build, where the
|
|
488
|
+
* argument is that reporting a working video service as dead is the worse of
|
|
489
|
+
* the two mistakes.
|
|
490
|
+
*/
|
|
491
|
+
export const LIVE_CONNECT_TIMEOUT_MS = 15_000;
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* How long a tile that WAS confirmed playing may go without another frame
|
|
495
|
+
* before it stops saying "Live".
|
|
496
|
+
*
|
|
497
|
+
* Only reachable once the video service reports frames at all (see
|
|
498
|
+
* `playerSignal`). It exists because the opposite mistake — a tile that went
|
|
499
|
+
* green once and stays green over a frozen picture — is the same defect as the
|
|
500
|
+
* mobile app's "Live" badge over a nine-second-old still, and is exactly what
|
|
501
|
+
* this whole rewrite is fixing.
|
|
502
|
+
*/
|
|
503
|
+
export const LIVE_FRAME_TIMEOUT_MS = 12_000;
|
|
504
|
+
|
|
505
|
+
export type TTileState =
|
|
506
|
+
| "live"
|
|
507
|
+
| "unverified"
|
|
508
|
+
| "connecting"
|
|
509
|
+
| "no-signal"
|
|
510
|
+
| "offline"
|
|
511
|
+
| "unavailable";
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The words a tile may say about itself.
|
|
515
|
+
*
|
|
516
|
+
* `offline` and `no-signal` are two different facts and used to share one word:
|
|
517
|
+
* "Offline" on every tile at once means the BROWSER lost the network; "No
|
|
518
|
+
* signal" on one tile means that camera is not answering. A supervisor needs to
|
|
519
|
+
* know which of those they are looking at.
|
|
520
|
+
*
|
|
521
|
+
* `unverified` is the state this wall is in for every camera on this estate
|
|
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.
|
|
529
|
+
*/
|
|
530
|
+
export const TILE_LABELS = {
|
|
531
|
+
live: "Live",
|
|
532
|
+
unverified: "Not confirmed",
|
|
533
|
+
connecting: "Connecting…",
|
|
534
|
+
"no-signal": "No signal",
|
|
535
|
+
offline: "No connection",
|
|
536
|
+
unavailable: "Not set up yet",
|
|
537
|
+
} as const;
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* What the browser knows about the embedded player page.
|
|
541
|
+
*
|
|
542
|
+
* - `loading` — nothing yet; the document request has not finished.
|
|
543
|
+
* - `page-up` — the document loaded. **This says nothing about pictures.**
|
|
544
|
+
* - `playing` — the video service reported a decoded frame (see `playerSignal`).
|
|
545
|
+
* - `stalled` — it reported frames and then stopped, or said it stalled.
|
|
546
|
+
* - `failed` — the document never loaded, or the browser reported an error.
|
|
547
|
+
*/
|
|
548
|
+
export type TPlayerState = "loading" | "page-up" | "playing" | "stalled" | "failed";
|
|
549
|
+
|
|
550
|
+
/* ------------------------------------------------------------------------- */
|
|
551
|
+
/* What the video service can tell us, and what it cannot */
|
|
552
|
+
/* ------------------------------------------------------------------------- */
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* EVERYTHING THIS BROWSER CAN HONESTLY KNOW ABOUT A CROSS-ORIGIN PLAYER.
|
|
556
|
+
*
|
|
557
|
+
* The player is another origin's page in an `<iframe>`, so the list is short
|
|
558
|
+
* and it is worth writing down, because the badge is built only from this:
|
|
559
|
+
*
|
|
560
|
+
* 1. **`load` fires** → that origin returned a document. Nothing more. The
|
|
561
|
+
* video service answers `GET /<channel>` with the same HTML for a channel
|
|
562
|
+
* that has no camera behind it, so `load` on a dead camera is normal.
|
|
563
|
+
* 2. **`load` never fires** → DNS, TLS or the connection failed, or the service
|
|
564
|
+
* is down. Only observable as *absence*, hence the timeout.
|
|
565
|
+
* 3. **`error` fires** → rare in practice for cross-origin frames, but free.
|
|
566
|
+
* 4. **`postMessage` from that origin** → the only channel that could carry
|
|
567
|
+
* "a frame decoded". The service does not send one today; `playerSignal`
|
|
568
|
+
* below is the listener, so the day it does, the wall goes green with no
|
|
569
|
+
* further frontend release.
|
|
570
|
+
*
|
|
571
|
+
* NOT available, and we must stop pretending otherwise: the frame rate, the
|
|
572
|
+
* canvas pixels, the WebSocket's state, whether ffmpeg upstream is producing
|
|
573
|
+
* anything. All of those live inside the other origin's document and the
|
|
574
|
+
* browser blocks every route to them — correctly.
|
|
575
|
+
*
|
|
576
|
+
* Opening our own WebSocket to the service to check would start a SECOND
|
|
577
|
+
* decode of the same camera per tile, which is a real load on the recorder for
|
|
578
|
+
* a status dot. Rejected.
|
|
579
|
+
*/
|
|
580
|
+
export type TPlayerSignal = "playing" | "stalled" | "failed" | null;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Reads a `postMessage` payload from the video service.
|
|
584
|
+
*
|
|
585
|
+
* Deliberately forgiving about shape and deliberately strict about origin —
|
|
586
|
+
* the origin check belongs to the caller, which has the camera's address; this
|
|
587
|
+
* only decides what a message MEANS. Anything unrecognised is `null`, which
|
|
588
|
+
* leaves the tile exactly where it was: an unknown message must never be able
|
|
589
|
+
* to turn a badge green.
|
|
590
|
+
*/
|
|
591
|
+
export function playerSignal(data: unknown): TPlayerSignal {
|
|
592
|
+
const event =
|
|
593
|
+
typeof data === "string"
|
|
594
|
+
? data
|
|
595
|
+
: data && typeof data === "object"
|
|
596
|
+
? String((data as Record<string, unknown>).event ?? (data as Record<string, unknown>).type ?? "")
|
|
597
|
+
: "";
|
|
598
|
+
|
|
599
|
+
switch (event.trim().toLowerCase()) {
|
|
600
|
+
case "frame":
|
|
601
|
+
case "videodecode":
|
|
602
|
+
case "playing":
|
|
603
|
+
return "playing";
|
|
604
|
+
case "stalled":
|
|
605
|
+
case "paused":
|
|
606
|
+
return "stalled";
|
|
607
|
+
case "error":
|
|
608
|
+
case "sourceclosed":
|
|
609
|
+
return "failed";
|
|
610
|
+
default:
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* The origin a message must come from to be believed, or `""` for an address
|
|
617
|
+
* that is not a URL at all. Kept here so the rule is tested rather than
|
|
618
|
+
* inlined in a component.
|
|
619
|
+
*/
|
|
620
|
+
export function playerOrigin(host: string | null | undefined): string {
|
|
621
|
+
if (!host) return "";
|
|
622
|
+
try {
|
|
623
|
+
return new URL(host).origin;
|
|
624
|
+
} catch {
|
|
625
|
+
return "";
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export type TTileView = {
|
|
630
|
+
state: TTileState;
|
|
631
|
+
label: string;
|
|
632
|
+
/**
|
|
633
|
+
* A sentence that COVERS THE TILE. Only for states where there is nothing
|
|
634
|
+
* worth looking at underneath it — never over a picture that may be fine.
|
|
635
|
+
*/
|
|
636
|
+
detail: string | null;
|
|
637
|
+
/**
|
|
638
|
+
* A quieter sentence for the badge's tooltip and the focused camera's panel.
|
|
639
|
+
* `unverified` uses this rather than `detail` for exactly one reason: there
|
|
640
|
+
* may well be a good picture under that badge, and a scrim over a working
|
|
641
|
+
* camera would be a worse lie than the one being fixed.
|
|
642
|
+
*/
|
|
643
|
+
note: string | null;
|
|
644
|
+
/** Whether the player page may be embedded at all. */
|
|
645
|
+
showPlayer: boolean;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
/**
|
|
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.
|
|
663
|
+
*/
|
|
664
|
+
export const UNVERIFIED_DETAIL =
|
|
665
|
+
"This page cannot confirm the picture is arriving, so check the tile yourself. If it is blank or frozen, report the camera.";
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Rule 4, plus every reason a tile may have nothing to show.
|
|
669
|
+
*
|
|
670
|
+
* Order matters: the browser being offline beats everything (it explains every
|
|
671
|
+
* tile at once), then the record's own problems, then the server's verdict on
|
|
672
|
+
* live video, and only then what the embedded page is actually doing.
|
|
673
|
+
*
|
|
674
|
+
* ## WHY THIS NO LONGER SAYS "Live" WHEN THE PAGE LOADS
|
|
675
|
+
*
|
|
676
|
+
* It used to, and it was wrong in production: on a live wall, one camera
|
|
677
|
+
* rendered pure white and another pure black, and both carried a green "Live".
|
|
678
|
+
* A third, on a nine-tile layout, was a black rectangle with a green dot. In
|
|
679
|
+
* every case the player page had loaded and no picture was arriving.
|
|
680
|
+
*
|
|
681
|
+
* The badge was asserting a fact nobody had checked. The video service returns
|
|
682
|
+
* its player HTML for any channel — including one with no camera behind it —
|
|
683
|
+
* so the `load` event proves the SERVICE is up and proves nothing at all about
|
|
684
|
+
* the CAMERA. Building "Live" on it is the same class of defect as the mobile
|
|
685
|
+
* app's "Live" badge over a nine-second-old still: a guard is told he is
|
|
686
|
+
* watching something he is not.
|
|
687
|
+
*
|
|
688
|
+
* So the page loading now earns `unverified`, and `live` is reserved for a
|
|
689
|
+
* frame the video service actually reported (`playerSignal`). Today no camera
|
|
690
|
+
* on this estate will reach `live`, and that is the honest answer rather than
|
|
691
|
+
* a comfortable one. The two-line change on the video service's side that
|
|
692
|
+
* would light these up is described in the PR; it is not ours to make.
|
|
693
|
+
*
|
|
694
|
+
* A tile deliberately does NOT decay from `unverified` to `no-signal` on a
|
|
695
|
+
* timer. Asserting death without evidence is the same mistake as asserting
|
|
696
|
+
* life without evidence, pointed the other way.
|
|
697
|
+
*/
|
|
698
|
+
export function tileView(
|
|
699
|
+
camera: TWallCamera | null | undefined,
|
|
700
|
+
opts: { online?: boolean; player?: TPlayerState } = {}
|
|
701
|
+
): TTileView {
|
|
702
|
+
const online = opts.online !== false;
|
|
703
|
+
const dead = (state: TTileState, detail: string | null): TTileView => ({
|
|
704
|
+
state,
|
|
705
|
+
label: TILE_LABELS[state],
|
|
706
|
+
detail,
|
|
707
|
+
note: detail,
|
|
708
|
+
showPlayer: false,
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
if (!camera) return dead("unavailable", "No camera in this position.");
|
|
712
|
+
if (!online)
|
|
713
|
+
return dead("offline", "This browser has no internet connection.");
|
|
714
|
+
// The server's refusal wins over anything reconstructed here. It covers the
|
|
715
|
+
// same two cases below AND the ones only it can know — chiefly "no recorder is
|
|
716
|
+
// configured for this camera's relay", which is the honest answer for every
|
|
717
|
+
// camera until the API host is configured, and which used to be replaced by
|
|
718
|
+
// our own guess.
|
|
719
|
+
const reason = camera.unavailableReason?.trim();
|
|
720
|
+
if (reason) return dead("unavailable", reason);
|
|
721
|
+
if (camera.status && camera.status !== "active")
|
|
722
|
+
return dead("unavailable", "This camera is not active.");
|
|
723
|
+
if (!camera.host) return dead("unavailable", "This camera has no address configured.");
|
|
724
|
+
|
|
725
|
+
const live = capabilityView(camera, "liveVideo");
|
|
726
|
+
if (live.state === "unsupported") return dead("unavailable", live.detail);
|
|
727
|
+
|
|
728
|
+
const player = opts.player ?? "loading";
|
|
729
|
+
const showing = (state: TTileState, detail: string | null): TTileView => ({
|
|
730
|
+
state,
|
|
731
|
+
label: TILE_LABELS[state],
|
|
732
|
+
detail,
|
|
733
|
+
note: detail,
|
|
734
|
+
showPlayer: true,
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
switch (player) {
|
|
738
|
+
case "failed":
|
|
739
|
+
// Invented: only the browser can know the page never loaded, so the
|
|
740
|
+
// server has no reason code for it. Worded as a fact, not a diagnosis —
|
|
741
|
+
// we genuinely cannot tell a dead camera from a dead video service.
|
|
742
|
+
return showing(
|
|
743
|
+
"no-signal",
|
|
744
|
+
"No picture. The camera or the video service may be down. Report it if it stays like this."
|
|
745
|
+
);
|
|
746
|
+
case "stalled":
|
|
747
|
+
// It WAS playing and stopped. That is the one case where we can say a
|
|
748
|
+
// picture has genuinely gone away, because we saw one arrive first.
|
|
749
|
+
return showing(
|
|
750
|
+
"no-signal",
|
|
751
|
+
"The picture stopped arriving. The camera or the video service may have dropped. Report it if it does not come back."
|
|
752
|
+
);
|
|
753
|
+
case "playing":
|
|
754
|
+
return showing("live", null);
|
|
755
|
+
case "page-up":
|
|
756
|
+
return { ...showing("unverified", null), note: UNVERIFIED_DETAIL };
|
|
757
|
+
default:
|
|
758
|
+
return showing("connecting", null);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* -------------------------------------------------------------------------- */
|
|
763
|
+
/* Health — RULE 6 */
|
|
764
|
+
/* -------------------------------------------------------------------------- */
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* One camera's health, as `GET /site-cameras/site/:siteId/health` returns it.
|
|
768
|
+
*
|
|
769
|
+
* **Reachability is a per-RECORDER answer**, cached per recorder on the server:
|
|
770
|
+
* twelve cameras hanging off one device is one TCP connect shared between the
|
|
771
|
+
* tiles, not twelve. That is why a wall may ask for this at all.
|
|
772
|
+
*
|
|
773
|
+
* **`firmwareVersion` and `deviceTime` are null on this estate and say why.**
|
|
774
|
+
* They are facts you get over the recorder's HTTP interface, which is not
|
|
775
|
+
* reachable — so the server sends `detailUnavailableReason` rather than leaving
|
|
776
|
+
* two empty fields to read as "checked, and fine".
|
|
777
|
+
*/
|
|
778
|
+
export type TWallCameraHealth = {
|
|
779
|
+
health?: "ok" | "drifted" | "unreachable" | "unsupported" | null;
|
|
780
|
+
reachable?: boolean | null;
|
|
781
|
+
reason?: string | null;
|
|
782
|
+
/** ISO timestamp of the last picture this server actually got. */
|
|
783
|
+
lastFrameAt?: string | null;
|
|
784
|
+
driftSeconds?: number | null;
|
|
785
|
+
firmwareVersion?: string | null;
|
|
786
|
+
deviceTime?: string | null;
|
|
787
|
+
detailUnavailableReason?: string | null;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
export type THealthTone = "ok" | "warn" | "down" | "unknown";
|
|
791
|
+
|
|
792
|
+
export type THealthView = {
|
|
793
|
+
tone: THealthTone;
|
|
794
|
+
/** Always a whole short phrase — never a bare word beside a dot. */
|
|
795
|
+
label: string;
|
|
796
|
+
/** The server's own sentence when it sent one. */
|
|
797
|
+
detail: string | null;
|
|
798
|
+
/** "4 min ago", or `null` when this server has never had a picture. */
|
|
799
|
+
lastFrame: string | null;
|
|
800
|
+
};
|
|
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
|
+
|
|
816
|
+
const HEALTH_LABELS: Record<string, { tone: THealthTone; label: string }> = {
|
|
817
|
+
ok: { tone: "ok", label: "Recorder responding" },
|
|
818
|
+
drifted: { tone: "warn", label: "Recorder clock is out" },
|
|
819
|
+
unreachable: { tone: "down", label: "Recorder not responding" },
|
|
820
|
+
unsupported: { tone: "unknown", label: "Recorder not checked" },
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Rule 6. This is about the RECORDER, and the tile's own badge is about the
|
|
825
|
+
* player page — the two are reported separately and never merged, because they
|
|
826
|
+
* genuinely disagree: a working relay in front of a dead recorder shows a live
|
|
827
|
+
* page and an unreachable device, and a supervisor who is told only "Live"
|
|
828
|
+
* learns the wrong thing.
|
|
829
|
+
*/
|
|
830
|
+
export function healthView(
|
|
831
|
+
health: TWallCameraHealth | null | undefined,
|
|
832
|
+
now: number = Date.now()
|
|
833
|
+
): THealthView {
|
|
834
|
+
const known = health?.health ? HEALTH_LABELS[health.health] : undefined;
|
|
835
|
+
const fallback: { tone: THealthTone; label: string } =
|
|
836
|
+
health?.reachable === true
|
|
837
|
+
? HEALTH_LABELS.ok
|
|
838
|
+
: health?.reachable === false
|
|
839
|
+
? HEALTH_LABELS.unreachable
|
|
840
|
+
: { tone: "unknown", label: "Recorder not checked yet" };
|
|
841
|
+
|
|
842
|
+
const { tone, label } = known ?? fallback;
|
|
843
|
+
|
|
844
|
+
return {
|
|
845
|
+
tone,
|
|
846
|
+
label,
|
|
847
|
+
detail: (health?.reason || "").trim() || null,
|
|
848
|
+
lastFrame: agoLabel(health?.lastFrameAt, now),
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* "just now" / "40s ago" / "6 min ago" / "3 h ago".
|
|
854
|
+
*
|
|
855
|
+
* Coarse on purpose above a minute: a supervisor is deciding whether a picture
|
|
856
|
+
* is CURRENT, and "6 min ago" answers that where "371s ago" makes them do
|
|
857
|
+
* arithmetic to reach the same answer.
|
|
858
|
+
*/
|
|
859
|
+
export function agoLabel(iso: string | null | undefined, now: number = Date.now()): string | null {
|
|
860
|
+
if (!iso) return null;
|
|
861
|
+
const at = Date.parse(iso);
|
|
862
|
+
if (!Number.isFinite(at)) return null;
|
|
863
|
+
|
|
864
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
|
865
|
+
if (seconds < 5) return "just now";
|
|
866
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
867
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`;
|
|
868
|
+
return `${Math.round(seconds / 3600)} h ago`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/* -------------------------------------------------------------------------- */
|
|
872
|
+
/* Digital zoom */
|
|
873
|
+
/* -------------------------------------------------------------------------- */
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* DIGITAL ZOOM IS CROPPING, NOT PTZ, AND THE DIFFERENCE IS THE WHOLE POINT.
|
|
877
|
+
*
|
|
878
|
+
* This magnifies the picture already on the screen. **No request leaves the
|
|
879
|
+
* browser and no hardware moves** — which is exactly why it works today on
|
|
880
|
+
* every camera while "Move camera" does not, and why the two must never look
|
|
881
|
+
* like the same control.
|
|
882
|
+
*
|
|
883
|
+
* The arithmetic is identical to the mobile build's (`zoomable.tsx`), including
|
|
884
|
+
* the 4x ceiling, so the same gesture gives the same result on a phone and on a
|
|
885
|
+
* laptop.
|
|
886
|
+
*/
|
|
887
|
+
export const MAX_DIGITAL_ZOOM = 4;
|
|
888
|
+
|
|
889
|
+
/** Beyond 4x a substream frame is enlarged pixels — a magnification that shows
|
|
890
|
+
* less than you started with is a control that lies. */
|
|
891
|
+
export function clampZoom(scale: number): number {
|
|
892
|
+
if (!Number.isFinite(scale)) return 1;
|
|
893
|
+
return Math.min(MAX_DIGITAL_ZOOM, Math.max(1, scale));
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Keeps the magnified picture covering the tile.
|
|
898
|
+
*
|
|
899
|
+
* At scale `s` the picture is `s` times the frame, so it may be moved by half
|
|
900
|
+
* the overflow each way and no further. Without this a drag walks the picture
|
|
901
|
+
* off the tile and leaves a supervisor looking at the background — arriving at
|
|
902
|
+
* the blank-rectangle failure this whole surface exists to prevent, by gesture.
|
|
903
|
+
*/
|
|
904
|
+
export function clampPan(
|
|
905
|
+
offset: { x: number; y: number },
|
|
906
|
+
scale: number,
|
|
907
|
+
frame: { width: number; height: number }
|
|
908
|
+
): { x: number; y: number } {
|
|
909
|
+
const limitX = Math.max(0, (frame.width * (scale - 1)) / 2);
|
|
910
|
+
const limitY = Math.max(0, (frame.height * (scale - 1)) / 2);
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
x: Math.min(limitX, Math.max(-limitX, offset.x || 0)),
|
|
914
|
+
y: Math.min(limitY, Math.max(-limitY, offset.y || 0)),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* One wheel notch. `deltaY` varies wildly between a mouse, a trackpad and a
|
|
920
|
+
* browser's delta mode, so the DIRECTION is used and the magnitude is not: a
|
|
921
|
+
* fixed step is predictable everywhere, and predictability is worth more here
|
|
922
|
+
* than proportionality.
|
|
923
|
+
*/
|
|
924
|
+
export function zoomStep(scale: number, deltaY: number): number {
|
|
925
|
+
return clampZoom(deltaY < 0 ? scale * 1.25 : scale / 1.25);
|
|
926
|
+
}
|