@7365admin1/layer-common 3.2.3 → 3.2.5
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 +251 -0
- package/components/CameraWall.vue +1034 -0
- package/components/CameraWallTile.vue +818 -0
- package/components/Layout/NavigationDrawer.vue +43 -2
- package/components/NavigationItem.vue +9 -0
- package/composables/useSiteSettings.ts +50 -0
- package/package.json +3 -1
- package/plugins/vuetify.ts +37 -9
- package/utils/camera-wall.test.ts +571 -0
- package/utils/camera-wall.ts +926 -0
- package/utils/theme.test.ts +201 -0
- package/utils/theme.ts +136 -0
- package/middleware/member.ts +0 -4
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="root" class="vms">
|
|
3
|
+
<!-- ------------------------------------------------------------ toolbar -->
|
|
4
|
+
<div class="vms__bar">
|
|
5
|
+
<button
|
|
6
|
+
type="button"
|
|
7
|
+
class="vms__tool"
|
|
8
|
+
:class="{ 'vms__tool--on': showPicker }"
|
|
9
|
+
:aria-pressed="showPicker"
|
|
10
|
+
@click="showPicker = !showPicker"
|
|
11
|
+
>
|
|
12
|
+
Cameras<span v-if="cameras.length" class="vms__count">{{ cameras.length }}</span>
|
|
13
|
+
</button>
|
|
14
|
+
|
|
15
|
+
<div class="vms__layouts" role="group" aria-label="Wall layout">
|
|
16
|
+
<button
|
|
17
|
+
v-for="l in WALL_LAYOUTS"
|
|
18
|
+
:key="l.key"
|
|
19
|
+
type="button"
|
|
20
|
+
class="vms__tool vms__tool--icon"
|
|
21
|
+
:class="{ 'vms__tool--on': layoutKey === l.key }"
|
|
22
|
+
:aria-pressed="layoutKey === l.key"
|
|
23
|
+
:title="`${l.label} (${l.key})`"
|
|
24
|
+
:aria-label="`${l.label} camera layout`"
|
|
25
|
+
@click="layoutKey = l.key"
|
|
26
|
+
>
|
|
27
|
+
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
|
28
|
+
<rect
|
|
29
|
+
v-for="cell in l.tiles"
|
|
30
|
+
:key="cell"
|
|
31
|
+
:x="((cell - 1) % l.columns) * (16 / l.columns) + 1"
|
|
32
|
+
:y="Math.floor((cell - 1) / l.columns) * (16 / l.columns) + 1"
|
|
33
|
+
:width="16 / l.columns - 2"
|
|
34
|
+
:height="16 / l.columns - 2"
|
|
35
|
+
fill="currentColor"
|
|
36
|
+
/>
|
|
37
|
+
</svg>
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="vms__spacer" />
|
|
42
|
+
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="vms__tool"
|
|
46
|
+
title="Fill the screen with the wall"
|
|
47
|
+
@click="toggleFullscreen"
|
|
48
|
+
>
|
|
49
|
+
Full screen
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<!-- -------------------------------------------------------------- body -->
|
|
54
|
+
<div class="vms__body">
|
|
55
|
+
<!--
|
|
56
|
+
THE CAMERA BROWSER. Beside the wall, not on top of it: a supervisor
|
|
57
|
+
changing what they watch should still be able to see what they are
|
|
58
|
+
watching.
|
|
59
|
+
-->
|
|
60
|
+
<aside v-if="showPicker" class="vms__picker" aria-label="Cameras on this site">
|
|
61
|
+
<p class="vms__picker-head">
|
|
62
|
+
On the wall
|
|
63
|
+
<button
|
|
64
|
+
v-if="selectedIds.length"
|
|
65
|
+
type="button"
|
|
66
|
+
class="vms__link"
|
|
67
|
+
@click="selectedIds = []"
|
|
68
|
+
>
|
|
69
|
+
Reset
|
|
70
|
+
</button>
|
|
71
|
+
</p>
|
|
72
|
+
<p v-if="!cameras.length && !loading" class="vms__picker-empty">
|
|
73
|
+
No cameras yet.
|
|
74
|
+
</p>
|
|
75
|
+
<label v-for="c in cameras" :key="c._id" class="vms__pick">
|
|
76
|
+
<input
|
|
77
|
+
type="checkbox"
|
|
78
|
+
:checked="isPicked(c._id)"
|
|
79
|
+
:disabled="!isPicked(c._id) && selectedIds.length >= layout.tiles"
|
|
80
|
+
@change="togglePick(c._id)"
|
|
81
|
+
>
|
|
82
|
+
<span class="vms__pick-name">{{ c.name || c._id }}</span>
|
|
83
|
+
<span
|
|
84
|
+
class="vms__pick-dot"
|
|
85
|
+
:class="c.status === 'active' ? 'vms__pick-dot--on' : 'vms__pick-dot--off'"
|
|
86
|
+
/>
|
|
87
|
+
</label>
|
|
88
|
+
<p v-if="selectedIds.length >= layout.tiles" class="vms__picker-note">
|
|
89
|
+
This layout holds {{ layout.tiles }}. Choose a bigger layout for more.
|
|
90
|
+
</p>
|
|
91
|
+
</aside>
|
|
92
|
+
|
|
93
|
+
<div class="vms__stage">
|
|
94
|
+
<!-- Loading, empty and failed are real states, not a blank rectangle. -->
|
|
95
|
+
<div v-if="loading" class="vms__state">
|
|
96
|
+
<p class="vms__state-head">Loading cameras…</p>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div v-else-if="failed" class="vms__state">
|
|
100
|
+
<p class="vms__state-head">Cameras could not be loaded</p>
|
|
101
|
+
<p class="vms__state-body">{{ failed }}</p>
|
|
102
|
+
<button type="button" class="vms__tool" @click="load()">Try again</button>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div v-else-if="!cameras.length" class="vms__state">
|
|
106
|
+
<p class="vms__state-head">No cameras set up yet</p>
|
|
107
|
+
<p class="vms__state-body">
|
|
108
|
+
CCTV cameras are added in Site Settings, under CCTV. Ask whoever manages
|
|
109
|
+
this site's settings to add one.
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div
|
|
114
|
+
v-else
|
|
115
|
+
class="vms__grid"
|
|
116
|
+
:style="{
|
|
117
|
+
gridTemplateColumns: `repeat(${layout.columns}, minmax(0, 1fr))`,
|
|
118
|
+
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
|
119
|
+
}"
|
|
120
|
+
>
|
|
121
|
+
<CameraWallTile
|
|
122
|
+
v-for="camera in tiles"
|
|
123
|
+
:key="camera._id"
|
|
124
|
+
:camera="camera"
|
|
125
|
+
:dense="dense"
|
|
126
|
+
:online="online"
|
|
127
|
+
:health="healthById[camera._id]"
|
|
128
|
+
:now="now"
|
|
129
|
+
:focused="camera._id === focused?._id"
|
|
130
|
+
@focus="focusedId = $event._id"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!--
|
|
137
|
+
THE PAGER.
|
|
138
|
+
|
|
139
|
+
Fixed under the wall rather than scrolling with the page, because it must
|
|
140
|
+
not scroll away from the wall it pages. Drawn only when there is more than
|
|
141
|
+
one page — a site with four cameras on a 2x2 wall does not need to be told
|
|
142
|
+
it is on page 1 of 1.
|
|
143
|
+
-->
|
|
144
|
+
<div v-if="pages > 1" class="vms__pager">
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
class="vms__tool"
|
|
148
|
+
:disabled="page === 0"
|
|
149
|
+
@click="page = Math.max(0, page - 1)"
|
|
150
|
+
>
|
|
151
|
+
Previous
|
|
152
|
+
</button>
|
|
153
|
+
<span class="vms__pager-label">Page {{ page + 1 }} of {{ pages }}</span>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
class="vms__tool"
|
|
157
|
+
:disabled="page >= pages - 1"
|
|
158
|
+
@click="page = Math.min(pages - 1, page + 1)"
|
|
159
|
+
>
|
|
160
|
+
Next
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<!-- ------------------------------------------------------- capabilities -->
|
|
165
|
+
<!--
|
|
166
|
+
THE CONTROLS THAT ARE NOT SWITCHED ON YET.
|
|
167
|
+
|
|
168
|
+
Drawn, dimmed, and carrying the server's own reason — never hidden. A
|
|
169
|
+
supervisor who cannot see that recorded footage exists as an idea cannot
|
|
170
|
+
ask for it, and hiding it is what stops anyone asking.
|
|
171
|
+
-->
|
|
172
|
+
<div v-if="focused" class="vms__caps">
|
|
173
|
+
<!--
|
|
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.
|
|
196
|
+
-->
|
|
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
|
+
-->
|
|
203
|
+
<span class="vms__health-state">
|
|
204
|
+
<span class="vms__health-dot" :class="`vms__health-dot--${focusedHealth.tone}`" />
|
|
205
|
+
{{ focusedHealth.label }}
|
|
206
|
+
</span>
|
|
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>
|
|
222
|
+
</span>
|
|
223
|
+
<span v-if="focusedHealth.detail" class="vms__health-item">
|
|
224
|
+
{{ focusedHealth.detail }}
|
|
225
|
+
</span>
|
|
226
|
+
<span v-if="healthFailed" class="vms__health-item">{{ healthFailed }}</span>
|
|
227
|
+
</div>
|
|
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
|
+
|
|
247
|
+
<div class="vms__caps-row">
|
|
248
|
+
<div
|
|
249
|
+
v-for="c in WALL_CAPABILITY_CONTROLS"
|
|
250
|
+
:key="c.key"
|
|
251
|
+
class="vms__cap"
|
|
252
|
+
:class="{ 'vms__cap--off': !capability(c.key).available }"
|
|
253
|
+
>
|
|
254
|
+
<p class="vms__cap-title">
|
|
255
|
+
{{ c.title }}
|
|
256
|
+
<span
|
|
257
|
+
class="vms__cap-tag"
|
|
258
|
+
:class="{ 'vms__cap-tag--on': capability(c.key).available }"
|
|
259
|
+
>
|
|
260
|
+
{{ capability(c.key).tag }}
|
|
261
|
+
</span>
|
|
262
|
+
</p>
|
|
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>
|
|
270
|
+
<!--
|
|
271
|
+
RULE 5. The line the server cannot write, because it does not know
|
|
272
|
+
who the reader can go and ask. Somebody read "Not set up yet" on this
|
|
273
|
+
screen and asked how to set it up — which is a fair question that the
|
|
274
|
+
screen was not answering. Suppressed here for the same reason as
|
|
275
|
+
the sentence above it: it is in the group note.
|
|
276
|
+
-->
|
|
277
|
+
<p v-if="!sharedNote && capability(c.key).nextStep" class="vms__cap-next">
|
|
278
|
+
{{ capability(c.key).nextStep }}
|
|
279
|
+
</p>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</template>
|
|
285
|
+
|
|
286
|
+
<script setup lang="ts">
|
|
287
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
288
|
+
|
|
289
|
+
import {
|
|
290
|
+
capabilityView,
|
|
291
|
+
clampPage,
|
|
292
|
+
DEFAULT_WALL_LAYOUT,
|
|
293
|
+
focusedCamera,
|
|
294
|
+
healthView,
|
|
295
|
+
isDenseLayout,
|
|
296
|
+
sharedCapabilityNote,
|
|
297
|
+
wallCameras,
|
|
298
|
+
wallLayout,
|
|
299
|
+
wallPageCount,
|
|
300
|
+
wallRows,
|
|
301
|
+
wallTiles,
|
|
302
|
+
tileView,
|
|
303
|
+
UNVERIFIED_DETAIL,
|
|
304
|
+
WALL_CAPABILITY_CONTROLS,
|
|
305
|
+
WALL_LAYOUTS,
|
|
306
|
+
type TWallCamera,
|
|
307
|
+
type TWallCameraHealth,
|
|
308
|
+
type TWallLayoutKey,
|
|
309
|
+
} from "../utils/camera-wall";
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* THE CAMERA WALL — one component, both applications.
|
|
313
|
+
*
|
|
314
|
+
* Security has had a wall for a while and Property Management has had none, so
|
|
315
|
+
* the same product answered the same question two different ways depending on
|
|
316
|
+
* which tab you were in. This is that wall, once, in the shared layer: the
|
|
317
|
+
* apps supply the site and nothing else.
|
|
318
|
+
*
|
|
319
|
+
* ## What it is, in one paragraph
|
|
320
|
+
*
|
|
321
|
+
* A dark video wall: a layout switcher, a camera browser beside the grid, tiles
|
|
322
|
+
* that carry a name and a live status, any tile expandable to the whole screen,
|
|
323
|
+
* and — under it — the camera controls that exist but are not switched on in
|
|
324
|
+
* this deployment yet, drawn and dimmed with the server's own explanation. It
|
|
325
|
+
* is the same shape as the monitoring surfaces in the iSecure365 mobile app,
|
|
326
|
+
* on purpose.
|
|
327
|
+
*
|
|
328
|
+
* ## Why the wall is dark in a light application
|
|
329
|
+
*
|
|
330
|
+
* A supervisor scanning tiles is judging PICTURES, and every bright surface
|
|
331
|
+
* beside a picture steals the contrast the picture needs. So the wall is dark
|
|
332
|
+
* in both themes, deliberately — the rest of the application is untouched, and
|
|
333
|
+
* a light-theme user getting a dark wall is the intended answer, not a missed
|
|
334
|
+
* token.
|
|
335
|
+
*
|
|
336
|
+
* ## What it deliberately does NOT do
|
|
337
|
+
*
|
|
338
|
+
* It does not decide who may see cameras. There is no camera-VIEWING permission
|
|
339
|
+
* in the product today — only the two `site-settings:manage-*-camera` strings,
|
|
340
|
+
* which govern SETUP — so each application gates its own menu entry with a
|
|
341
|
+
* permission that actually resolves, and this component draws what it is given.
|
|
342
|
+
* Inventing a gate here would silently deny people access they have today.
|
|
343
|
+
*/
|
|
344
|
+
|
|
345
|
+
const props = defineProps<{ site: string }>();
|
|
346
|
+
|
|
347
|
+
/* ---------------------------------------------------------------- the data */
|
|
348
|
+
|
|
349
|
+
const { getSiteWall, getSiteHealth } = useSiteSettings();
|
|
350
|
+
|
|
351
|
+
const cameras = ref<TWallCamera[]>([]);
|
|
352
|
+
const loading = ref(true);
|
|
353
|
+
const failed = ref("");
|
|
354
|
+
/**
|
|
355
|
+
* The server's own tuning. `healthCacheSeconds` is how long IT caches a
|
|
356
|
+
* recorder probe, so it is exactly how often there is any point asking — and
|
|
357
|
+
* asking faster would spend round trips to be handed the same cached answer.
|
|
358
|
+
*/
|
|
359
|
+
const config = ref<Record<string, any> | null>(null);
|
|
360
|
+
|
|
361
|
+
async function load() {
|
|
362
|
+
loading.value = true;
|
|
363
|
+
failed.value = "";
|
|
364
|
+
try {
|
|
365
|
+
// The site's wall, from the endpoint built for it. `wallCameras` still
|
|
366
|
+
// filters on the way in — the server already returns `type: "ip"` only, and
|
|
367
|
+
// an ANPR unit must never reach a monitoring wall even if that ever changes.
|
|
368
|
+
const res = await getSiteWall(props.site);
|
|
369
|
+
cameras.value = wallCameras(res?.cameras);
|
|
370
|
+
config.value = res?.config ?? null;
|
|
371
|
+
} catch (error: any) {
|
|
372
|
+
// The raw message is often a stack trace and rarely something an operator
|
|
373
|
+
// can act on — but "you may not see this site" and "your connection
|
|
374
|
+
// dropped" are genuinely different problems with different next steps, and
|
|
375
|
+
// telling a refused caller to check their connection sends them off to
|
|
376
|
+
// debug their wifi. The server answers a site that is not the caller's the
|
|
377
|
+
// same way as a site that does not exist, on purpose, so this wording does
|
|
378
|
+
// not distinguish them either.
|
|
379
|
+
cameras.value = [];
|
|
380
|
+
const status = Number(error?.statusCode ?? error?.response?.status ?? 0);
|
|
381
|
+
failed.value =
|
|
382
|
+
status === 401 || status === 403 || status === 404
|
|
383
|
+
? "You do not have access to this site's cameras."
|
|
384
|
+
: "The list of cameras did not load. Check your connection and try again.";
|
|
385
|
+
} finally {
|
|
386
|
+
loading.value = false;
|
|
387
|
+
restartHealth();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
onMounted(load);
|
|
392
|
+
watch(() => props.site, () => {
|
|
393
|
+
selectedIds.value = [];
|
|
394
|
+
page.value = 0;
|
|
395
|
+
healthById.value = {};
|
|
396
|
+
load();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
/* -------------------------------------------------------------- the layout */
|
|
400
|
+
|
|
401
|
+
const layoutKey = ref<TWallLayoutKey>(DEFAULT_WALL_LAYOUT);
|
|
402
|
+
const layout = computed(() => wallLayout(layoutKey.value));
|
|
403
|
+
const dense = computed(() => isDenseLayout(layoutKey.value));
|
|
404
|
+
|
|
405
|
+
const selectedIds = ref<string[]>([]);
|
|
406
|
+
const showPicker = ref(false);
|
|
407
|
+
|
|
408
|
+
const page = ref(0);
|
|
409
|
+
const pages = computed(() =>
|
|
410
|
+
wallPageCount(cameras.value, selectedIds.value, layoutKey.value)
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const tiles = computed(() =>
|
|
414
|
+
wallTiles(cameras.value, selectedIds.value, layoutKey.value, page.value)
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* THE GRID PACKS TO WHAT IS ON IT.
|
|
419
|
+
*
|
|
420
|
+
* Five cameras on a nine-tile wall used to draw five pictures and four dashed
|
|
421
|
+
* boxes reading "No camera in this position", with the pictures squeezed into
|
|
422
|
+
* two thirds of the height to leave room for the holes. The layout picker means
|
|
423
|
+
* "at most this many, at this size" — it does not mean the screen owes anybody
|
|
424
|
+
* four empty rectangles. Nothing can be done with an empty cell from here
|
|
425
|
+
* anyway: cameras are added in Site Settings.
|
|
426
|
+
*/
|
|
427
|
+
const rows = computed(() => wallRows(tiles.value.length, layoutKey.value));
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* A layout change resizes the page, so the old page NUMBER no longer means
|
|
431
|
+
* anything — going to 3x3 from page 2 of a 2x2 wall would land somewhere
|
|
432
|
+
* arbitrary, or past the end. Clamped rather than reset to 0: dropping from
|
|
433
|
+
* 3x3 to 2x2 keeps you roughly where you were instead of throwing you back to
|
|
434
|
+
* the front of a wall you were part-way through.
|
|
435
|
+
*/
|
|
436
|
+
watch([layoutKey, pages], () => {
|
|
437
|
+
page.value = clampPage(page.value, pages.value);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
function isPicked(id: string) {
|
|
441
|
+
return selectedIds.value.includes(id);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function togglePick(id: string) {
|
|
445
|
+
if (isPicked(id)) selectedIds.value = selectedIds.value.filter((x) => x !== id);
|
|
446
|
+
else if (selectedIds.value.length < layout.value.tiles)
|
|
447
|
+
selectedIds.value = [...selectedIds.value, id];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* --------------------------------------------------------- what is focused */
|
|
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
|
+
*/
|
|
472
|
+
const focusedId = ref("");
|
|
473
|
+
const focused = computed(() => focusedCamera(tiles.value, focusedId.value));
|
|
474
|
+
|
|
475
|
+
function capability(key: string) {
|
|
476
|
+
return capabilityView(focused.value, key);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
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"
|
|
503
|
+
* badge is something its tile can ever show. A camera the server has already
|
|
504
|
+
* refused has a reason of its own on the tile and does not need this one too.
|
|
505
|
+
*/
|
|
506
|
+
const embedsPlayer = computed(
|
|
507
|
+
() => tileView(focused.value, { online: online.value }).showPlayer
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
/* ------------------------------------------------------------------ health */
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* THE RECORDER'S OWN STATE, asked for separately and slowly.
|
|
514
|
+
*
|
|
515
|
+
* This is the only thing on this screen that can cause the server to touch a
|
|
516
|
+
* device, so three things bound it and all three matter:
|
|
517
|
+
*
|
|
518
|
+
* - **only the tiles on screen** are asked about, so paging to another page
|
|
519
|
+
* does not keep probing the page you left;
|
|
520
|
+
* - **the interval is the server's own cache lifetime**, because asking faster
|
|
521
|
+
* than the cache is round trips in exchange for the identical answer;
|
|
522
|
+
* - **a failure is never retried and never breaks the wall.** Health is
|
|
523
|
+
* supporting information; a wall that goes blank because a probe timed out
|
|
524
|
+
* would be a worse screen than one with no health on it at all.
|
|
525
|
+
*/
|
|
526
|
+
const healthById = ref<Record<string, TWallCameraHealth>>({});
|
|
527
|
+
const healthFailed = ref("");
|
|
528
|
+
/** Ticked with the sweep so "4 min ago" ages instead of freezing. */
|
|
529
|
+
const now = ref(Date.now());
|
|
530
|
+
|
|
531
|
+
const focusedHealth = computed(() =>
|
|
532
|
+
healthView(focused.value ? healthById.value[focused.value._id] : null, now.value)
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const visibleIds = computed(() => tiles.value.map((c) => c._id));
|
|
536
|
+
|
|
537
|
+
let healthTimer: ReturnType<typeof setInterval> | null = null;
|
|
538
|
+
|
|
539
|
+
async function sweepHealth() {
|
|
540
|
+
const ids = visibleIds.value;
|
|
541
|
+
now.value = Date.now();
|
|
542
|
+
if (!ids.length || !online.value) return;
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
const res = await getSiteHealth(props.site, ids);
|
|
546
|
+
const next: Record<string, TWallCameraHealth> = {};
|
|
547
|
+
for (const item of (res?.cameras ?? []) as any[]) {
|
|
548
|
+
if (item?._id) next[String(item._id)] = item as TWallCameraHealth;
|
|
549
|
+
}
|
|
550
|
+
healthById.value = next;
|
|
551
|
+
healthFailed.value = "";
|
|
552
|
+
} catch {
|
|
553
|
+
// Kept, not cleared: the last known health is still the most useful thing
|
|
554
|
+
// we have, and "the recorder was fine a moment ago" is a real answer.
|
|
555
|
+
healthFailed.value = "Could not check the recorder just now.";
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function restartHealth() {
|
|
560
|
+
if (healthTimer) clearInterval(healthTimer);
|
|
561
|
+
const seconds = Number(config.value?.healthCacheSeconds);
|
|
562
|
+
const intervalMs = (Number.isFinite(seconds) && seconds > 0 ? seconds : 20) * 1000;
|
|
563
|
+
healthTimer = setInterval(sweepHealth, intervalMs);
|
|
564
|
+
sweepHealth();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Re-swept when the visible set changes, so a supervisor who pages or changes
|
|
568
|
+
// layout is not reading the previous page's health under the new page's names.
|
|
569
|
+
watch(visibleIds, (ids, previous) => {
|
|
570
|
+
if (ids.join(",") !== (previous ?? []).join(",")) sweepHealth();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
onBeforeUnmount(() => {
|
|
574
|
+
if (healthTimer) clearInterval(healthTimer);
|
|
575
|
+
healthTimer = null;
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
/* ------------------------------------------------------- browser condition */
|
|
579
|
+
|
|
580
|
+
const online = ref(true);
|
|
581
|
+
const setOnline = () => {
|
|
582
|
+
online.value = typeof navigator === "undefined" ? true : navigator.onLine !== false;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
onMounted(() => {
|
|
586
|
+
setOnline();
|
|
587
|
+
window.addEventListener("online", setOnline);
|
|
588
|
+
window.addEventListener("offline", setOnline);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
onBeforeUnmount(() => {
|
|
592
|
+
window.removeEventListener("online", setOnline);
|
|
593
|
+
window.removeEventListener("offline", setOnline);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
/* ------------------------------------------------------------- fullscreen */
|
|
597
|
+
|
|
598
|
+
const root = ref<HTMLElement | null>(null);
|
|
599
|
+
|
|
600
|
+
async function toggleFullscreen() {
|
|
601
|
+
const el = root.value;
|
|
602
|
+
if (!el) return;
|
|
603
|
+
try {
|
|
604
|
+
if (document.fullscreenElement) await document.exitFullscreen();
|
|
605
|
+
else await el.requestFullscreen();
|
|
606
|
+
} catch {
|
|
607
|
+
// A browser that refuses fullscreen is not worth interrupting anyone over.
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
</script>
|
|
611
|
+
|
|
612
|
+
<style scoped>
|
|
613
|
+
/*
|
|
614
|
+
The near-black ramp, the gutter and the radius are the same values the mobile
|
|
615
|
+
monitoring build uses, so the two surfaces read as one product. They are
|
|
616
|
+
literals rather than theme tokens because this surface is dark in BOTH themes
|
|
617
|
+
and therefore cannot read one.
|
|
618
|
+
*/
|
|
619
|
+
.vms {
|
|
620
|
+
--vms-screen: #07090c;
|
|
621
|
+
--vms-tile: #101418;
|
|
622
|
+
--vms-chrome: #161b22;
|
|
623
|
+
--vms-chrome-active: #232b36;
|
|
624
|
+
--vms-line: #232b36;
|
|
625
|
+
--vms-scrim: rgba(7, 9, 12, 0.62);
|
|
626
|
+
--vms-scrim-solid: rgba(7, 9, 12, 0.82);
|
|
627
|
+
--vms-text: #e8ecf1;
|
|
628
|
+
--vms-text-dim: #94a0b0;
|
|
629
|
+
/*
|
|
630
|
+
#5c6675 measured 2.98:1 against the chrome — under the 3:1 a status
|
|
631
|
+
indicator needs, and this token IS a status indicator (the "not checked"
|
|
632
|
+
dot and the inactive dot in the camera list). #7a8697 measures 4.68:1 there
|
|
633
|
+
and 5.01:1 on a tile. Invented value; the rest of the ramp is unchanged.
|
|
634
|
+
*/
|
|
635
|
+
--vms-text-faint: #7a8697;
|
|
636
|
+
--vms-accent: #5b9be0;
|
|
637
|
+
--vms-radius: 3px;
|
|
638
|
+
|
|
639
|
+
display: flex;
|
|
640
|
+
flex-direction: column;
|
|
641
|
+
background: var(--vms-screen);
|
|
642
|
+
border-radius: var(--vms-radius);
|
|
643
|
+
overflow: hidden;
|
|
644
|
+
color: var(--vms-text);
|
|
645
|
+
font-family: inherit;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.vms__bar {
|
|
649
|
+
display: flex;
|
|
650
|
+
align-items: center;
|
|
651
|
+
gap: 6px;
|
|
652
|
+
padding: 6px;
|
|
653
|
+
background: var(--vms-chrome);
|
|
654
|
+
border-bottom: 1px solid var(--vms-line);
|
|
655
|
+
flex-wrap: wrap;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.vms__spacer {
|
|
659
|
+
flex: 1 1 auto;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.vms__layouts {
|
|
663
|
+
display: flex;
|
|
664
|
+
gap: 2px;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.vms__tool {
|
|
668
|
+
display: inline-flex;
|
|
669
|
+
align-items: center;
|
|
670
|
+
gap: 6px;
|
|
671
|
+
min-height: 28px;
|
|
672
|
+
padding: 4px 10px;
|
|
673
|
+
border: 1px solid var(--vms-line);
|
|
674
|
+
border-radius: var(--vms-radius);
|
|
675
|
+
background: transparent;
|
|
676
|
+
color: var(--vms-text);
|
|
677
|
+
font-size: 12px;
|
|
678
|
+
line-height: 16px;
|
|
679
|
+
cursor: pointer;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.vms__tool:hover:not(:disabled) {
|
|
683
|
+
background: var(--vms-chrome-active);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.vms__tool:disabled {
|
|
687
|
+
/* Legible, not ghosted: see the note on the capability panel below. */
|
|
688
|
+
opacity: 0.55;
|
|
689
|
+
cursor: default;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.vms__tool--icon {
|
|
693
|
+
padding: 4px 8px;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/*
|
|
697
|
+
A SELECTED TOOL IS NOT PAINTED IN THE BRAND RED. On this screen red already
|
|
698
|
+
means "this camera is down"; a permanent red block in the toolbar of a
|
|
699
|
+
monitoring wall competes with the alarms the wall exists to show. Selection is
|
|
700
|
+
a raised neutral fill and a 2 px accent underline instead.
|
|
701
|
+
*/
|
|
702
|
+
.vms__tool--on {
|
|
703
|
+
background: var(--vms-chrome-active);
|
|
704
|
+
box-shadow: inset 0 -2px 0 var(--vms-accent);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.vms__tool:focus-visible {
|
|
708
|
+
outline: 2px solid var(--vms-accent);
|
|
709
|
+
outline-offset: 1px;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.vms__count {
|
|
713
|
+
color: var(--vms-text-dim);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.vms__body {
|
|
717
|
+
display: flex;
|
|
718
|
+
min-height: 0;
|
|
719
|
+
height: 68vh;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.vms__picker {
|
|
723
|
+
width: 220px;
|
|
724
|
+
flex: none;
|
|
725
|
+
overflow-y: auto;
|
|
726
|
+
padding: 8px;
|
|
727
|
+
background: var(--vms-chrome);
|
|
728
|
+
border-right: 1px solid var(--vms-line);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.vms__picker-head {
|
|
732
|
+
display: flex;
|
|
733
|
+
align-items: center;
|
|
734
|
+
justify-content: space-between;
|
|
735
|
+
margin: 0 0 6px;
|
|
736
|
+
font-size: 11px;
|
|
737
|
+
letter-spacing: 0.06em;
|
|
738
|
+
text-transform: uppercase;
|
|
739
|
+
color: var(--vms-text-dim);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.vms__picker-empty,
|
|
743
|
+
.vms__picker-note {
|
|
744
|
+
margin: 6px 0 0;
|
|
745
|
+
font-size: 11px;
|
|
746
|
+
line-height: 15px;
|
|
747
|
+
color: var(--vms-text-dim);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.vms__link {
|
|
751
|
+
background: none;
|
|
752
|
+
border: none;
|
|
753
|
+
color: var(--vms-accent);
|
|
754
|
+
font-size: 11px;
|
|
755
|
+
cursor: pointer;
|
|
756
|
+
padding: 0;
|
|
757
|
+
text-transform: none;
|
|
758
|
+
letter-spacing: 0;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.vms__pick {
|
|
762
|
+
display: flex;
|
|
763
|
+
align-items: center;
|
|
764
|
+
gap: 8px;
|
|
765
|
+
/* Dense chrome, but a 40 px row so a checkbox is not a lottery to hit. */
|
|
766
|
+
min-height: 40px;
|
|
767
|
+
padding: 0 4px;
|
|
768
|
+
font-size: 12px;
|
|
769
|
+
cursor: pointer;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.vms__pick-name {
|
|
773
|
+
flex: 1 1 auto;
|
|
774
|
+
overflow: hidden;
|
|
775
|
+
text-overflow: ellipsis;
|
|
776
|
+
white-space: nowrap;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.vms__pick-dot {
|
|
780
|
+
width: 7px;
|
|
781
|
+
height: 7px;
|
|
782
|
+
border-radius: 50%;
|
|
783
|
+
flex: none;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
.vms__pick-dot--on {
|
|
787
|
+
background: #4caf50;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
.vms__pick-dot--off {
|
|
791
|
+
background: var(--vms-text-faint);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
.vms__stage {
|
|
795
|
+
flex: 1 1 auto;
|
|
796
|
+
min-width: 0;
|
|
797
|
+
display: flex;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.vms__grid {
|
|
801
|
+
flex: 1 1 auto;
|
|
802
|
+
display: grid;
|
|
803
|
+
/* 2 px: on a nine-tile wall the usual 8 px rhythm costs real picture. */
|
|
804
|
+
gap: 2px;
|
|
805
|
+
padding: 2px;
|
|
806
|
+
min-width: 0;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
.vms__state {
|
|
810
|
+
flex: 1 1 auto;
|
|
811
|
+
display: flex;
|
|
812
|
+
flex-direction: column;
|
|
813
|
+
align-items: center;
|
|
814
|
+
justify-content: center;
|
|
815
|
+
gap: 8px;
|
|
816
|
+
padding: 24px;
|
|
817
|
+
text-align: center;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
.vms__state-head {
|
|
821
|
+
margin: 0;
|
|
822
|
+
font-size: 14px;
|
|
823
|
+
font-weight: 600;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
.vms__state-body {
|
|
827
|
+
margin: 0;
|
|
828
|
+
max-width: 46ch;
|
|
829
|
+
font-size: 12px;
|
|
830
|
+
line-height: 18px;
|
|
831
|
+
color: var(--vms-text-dim);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/* ------------------------------------------------------------------ pager */
|
|
835
|
+
|
|
836
|
+
.vms__pager {
|
|
837
|
+
display: flex;
|
|
838
|
+
align-items: center;
|
|
839
|
+
justify-content: center;
|
|
840
|
+
gap: 10px;
|
|
841
|
+
padding: 6px;
|
|
842
|
+
background: var(--vms-chrome);
|
|
843
|
+
border-top: 1px solid var(--vms-line);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.vms__pager-label {
|
|
847
|
+
font-size: 12px;
|
|
848
|
+
color: var(--vms-text-dim);
|
|
849
|
+
font-variant-numeric: tabular-nums;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/* ----------------------------------------------------------- badge meaning */
|
|
853
|
+
|
|
854
|
+
.vms__badge-note {
|
|
855
|
+
margin: 0 0 8px;
|
|
856
|
+
max-width: 92ch;
|
|
857
|
+
font-size: 12px;
|
|
858
|
+
line-height: 17px;
|
|
859
|
+
color: var(--vms-text-dim);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/* ----------------------------------------------------------------- health */
|
|
863
|
+
|
|
864
|
+
.vms__health {
|
|
865
|
+
display: flex;
|
|
866
|
+
flex-wrap: wrap;
|
|
867
|
+
align-items: baseline;
|
|
868
|
+
gap: 6px 16px;
|
|
869
|
+
margin: 0 0 10px;
|
|
870
|
+
font-size: 12px;
|
|
871
|
+
line-height: 17px;
|
|
872
|
+
color: var(--vms-text-dim);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.vms__health-state {
|
|
876
|
+
display: inline-flex;
|
|
877
|
+
align-items: center;
|
|
878
|
+
gap: 6px;
|
|
879
|
+
color: var(--vms-text);
|
|
880
|
+
font-weight: 600;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.vms__health-item b {
|
|
884
|
+
color: var(--vms-text);
|
|
885
|
+
font-weight: 600;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
.vms__health-dot {
|
|
889
|
+
width: 7px;
|
|
890
|
+
height: 7px;
|
|
891
|
+
border-radius: 50%;
|
|
892
|
+
flex: none;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
.vms__health-dot--ok {
|
|
896
|
+
background: #4caf50;
|
|
897
|
+
}
|
|
898
|
+
.vms__health-dot--warn {
|
|
899
|
+
background: #fb8c00;
|
|
900
|
+
}
|
|
901
|
+
.vms__health-dot--down {
|
|
902
|
+
background: #e0241c;
|
|
903
|
+
}
|
|
904
|
+
.vms__health-dot--unknown {
|
|
905
|
+
background: var(--vms-text-faint);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/* ------------------------------------------------------------ capabilities */
|
|
909
|
+
|
|
910
|
+
.vms__caps {
|
|
911
|
+
padding: 10px;
|
|
912
|
+
background: var(--vms-chrome);
|
|
913
|
+
border-top: 1px solid var(--vms-line);
|
|
914
|
+
}
|
|
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
|
+
*/
|
|
921
|
+
.vms__caps-head {
|
|
922
|
+
margin: 0 0 6px;
|
|
923
|
+
font-size: 11px;
|
|
924
|
+
letter-spacing: 0.06em;
|
|
925
|
+
text-transform: uppercase;
|
|
926
|
+
color: var(--vms-text-dim);
|
|
927
|
+
}
|
|
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
|
+
|
|
941
|
+
.vms__caps-row {
|
|
942
|
+
display: grid;
|
|
943
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
944
|
+
gap: 8px;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/*
|
|
948
|
+
A CONTROL THAT IS OFF IS OUTLINED, NOT FADED.
|
|
949
|
+
|
|
950
|
+
A faded fill is a live control with the lights down; an outline is the shape
|
|
951
|
+
of a control that is not there yet. The accent rail on the left says the same
|
|
952
|
+
thing again: a defined slot, waiting to be filled — which is literally true.
|
|
953
|
+
*/
|
|
954
|
+
.vms__cap {
|
|
955
|
+
padding: 8px 10px;
|
|
956
|
+
border: 1px solid var(--vms-line);
|
|
957
|
+
border-left: 2px solid var(--vms-accent);
|
|
958
|
+
border-radius: var(--vms-radius);
|
|
959
|
+
background: transparent;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
.vms__cap-title {
|
|
963
|
+
margin: 0 0 2px;
|
|
964
|
+
display: flex;
|
|
965
|
+
align-items: center;
|
|
966
|
+
gap: 8px;
|
|
967
|
+
font-size: 12px;
|
|
968
|
+
font-weight: 600;
|
|
969
|
+
/* The TITLE stays legible even when the control is off: somebody who cannot
|
|
970
|
+
read "Recorded footage" cannot ask for it. */
|
|
971
|
+
color: var(--vms-text);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
.vms__cap-tag {
|
|
975
|
+
font-size: 11px;
|
|
976
|
+
font-weight: 400;
|
|
977
|
+
padding: 1px 6px;
|
|
978
|
+
border-radius: var(--vms-radius);
|
|
979
|
+
background: var(--vms-chrome-active);
|
|
980
|
+
color: var(--vms-text-dim);
|
|
981
|
+
white-space: nowrap;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/*
|
|
985
|
+
"Ready" is the one tag that is good news, and it is the accent rather than
|
|
986
|
+
green: green on this screen means "this camera is up", and a control being
|
|
987
|
+
available is not the same claim.
|
|
988
|
+
*/
|
|
989
|
+
.vms__cap-tag--on {
|
|
990
|
+
color: var(--vms-accent);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/*
|
|
994
|
+
RULE 5's second line: quieter than the server's sentence above it, because it
|
|
995
|
+
is advice rather than fact — but NOT dimmed with the rest of a switched-off
|
|
996
|
+
panel. It is the one line on an unavailable control that is actionable, and
|
|
997
|
+
fading the only actionable line is the whole mistake this fixes.
|
|
998
|
+
*/
|
|
999
|
+
.vms__cap-next {
|
|
1000
|
+
margin: 6px 0 0;
|
|
1001
|
+
padding-top: 6px;
|
|
1002
|
+
border-top: 1px solid var(--vms-line);
|
|
1003
|
+
font-size: 11px;
|
|
1004
|
+
line-height: 16px;
|
|
1005
|
+
color: var(--vms-text-dim);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/*
|
|
1009
|
+
THE FADE ON A SWITCHED-OFF REASON IS GONE, AND THE OLD COMMENT WAS WRONG.
|
|
1010
|
+
|
|
1011
|
+
It claimed 0.55 opacity measured "about 5.2:1". Measured properly — the dim
|
|
1012
|
+
ramp #94a0b0 composited at 0.55 over the chrome #161b22 — it is 2.88:1,
|
|
1013
|
+
well under the 4.5:1 a sentence needs, and the reason a supervisor is meant
|
|
1014
|
+
to read was the least legible text on the panel. Removing the fade entirely
|
|
1015
|
+
leaves #94a0b0 on #161b22 at 6.52:1. The panel is already outlined rather
|
|
1016
|
+
than filled, which is what says "not switched on"; the fade was saying it a
|
|
1017
|
+
second time at the cost of the words.
|
|
1018
|
+
*/
|
|
1019
|
+
.vms__cap-detail {
|
|
1020
|
+
margin: 0;
|
|
1021
|
+
font-size: 12px;
|
|
1022
|
+
line-height: 17px;
|
|
1023
|
+
color: var(--vms-text-dim);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.vms:fullscreen .vms__body {
|
|
1027
|
+
height: auto;
|
|
1028
|
+
flex: 1 1 auto;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
.vms:fullscreen {
|
|
1032
|
+
height: 100vh;
|
|
1033
|
+
}
|
|
1034
|
+
</style>
|