@7365admin1/layer-common 3.1.4-staging.54 → 3.1.4-staging.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ <template>
2
+ <div
3
+ ref="root"
4
+ class="vms-tile"
5
+ :class="{ 'vms-tile--dense': dense, 'vms-tile--focused': focused, 'vms-tile--empty': !camera }"
6
+ :tabindex="camera ? 0 : -1"
7
+ :aria-label="camera ? `${camera.name || camera._id}, ${view.label}` : 'Empty position'"
8
+ @click="camera && $emit('focus', camera)"
9
+ @dblclick="camera && toggleFullscreen()"
10
+ @keydown.enter="camera && toggleFullscreen()"
11
+ >
12
+ <!--
13
+ The picture, when there is one to show. This is an embed of the video
14
+ service's own player page: the stored address IS the live view, and there
15
+ is no other transport for live web video today.
16
+ -->
17
+ <iframe
18
+ v-if="camera && view.showPlayer"
19
+ :key="camera.host"
20
+ :src="camera.host"
21
+ class="vms-tile__frame"
22
+ :title="`Live view of ${camera.name || 'camera'}`"
23
+ allowfullscreen
24
+ referrerpolicy="no-referrer"
25
+ @load="onPlayerLoad"
26
+ />
27
+
28
+ <!--
29
+ A tile with nothing to show says so across the whole tile, not in a
30
+ corner. At nine tiles a supervisor is scanning, and a one-line reason
31
+ tucked under a black rectangle is exactly the thing that gets missed.
32
+ -->
33
+ <div v-if="view.detail" class="vms-tile__overlay">
34
+ <p class="vms-tile__reason">{{ view.detail }}</p>
35
+ </div>
36
+
37
+ <!-- Chrome sits OVER the picture, never around it. -->
38
+ <div v-if="camera" class="vms-tile__status">
39
+ <span class="vms-tile__dot" :class="`vms-tile__dot--${view.state}`" />
40
+ <!--
41
+ At nine tiles the badge is the DOT ALONE: a status word beside a 6 px
42
+ dot on a small tile is unreadable at the distance a wall is scanned
43
+ from, and the dot is what is being read anyway. The word returns at
44
+ four tiles and one.
45
+ -->
46
+ <span v-if="!dense" class="vms-tile__state">{{ view.label }}</span>
47
+ </div>
48
+
49
+ <div v-if="camera" class="vms-tile__chrome">
50
+ <!-- The NAME wins the fight for room: it is the tile's entire job. -->
51
+ <span class="vms-tile__name">{{ camera.name || camera._id }}</span>
52
+ </div>
53
+
54
+ <button
55
+ v-if="camera"
56
+ type="button"
57
+ class="vms-tile__expand"
58
+ title="Fill the screen with this camera"
59
+ aria-label="Fill the screen with this camera"
60
+ @click.stop="toggleFullscreen()"
61
+ >
62
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
63
+ <path
64
+ d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
65
+ fill="none"
66
+ stroke="currentColor"
67
+ stroke-width="2"
68
+ stroke-linecap="square"
69
+ />
70
+ </svg>
71
+ </button>
72
+ </div>
73
+ </template>
74
+
75
+ <script setup lang="ts">
76
+ import { computed, onBeforeUnmount, ref, watch } from "vue";
77
+
78
+ import {
79
+ LIVE_CONNECT_TIMEOUT_MS,
80
+ tileView,
81
+ type TPlayerState,
82
+ type TWallCamera,
83
+ } from "../utils/camera-wall";
84
+
85
+ /**
86
+ * ONE TILE OF THE CAMERA WALL.
87
+ *
88
+ * Everything this component decides comes from `utils/camera-wall.ts`; what is
89
+ * left here is the picture, the chrome over it, and the one thing only a
90
+ * browser can know — whether the player page ever loaded.
91
+ *
92
+ * ## The ceiling worth knowing before "improving" the live badge
93
+ *
94
+ * We can tell when the player PAGE loads. We cannot tell when a FRAME decodes:
95
+ * the page belongs to the video service, not to us, and learning more would
96
+ * mean reaching into another origin's document — which is both blocked by the
97
+ * browser and would break silently the day that page changes. So **"Live" means
98
+ * the player page is up**. If the service is up and the camera behind it is
99
+ * dead, the operator sees that service's own empty player, exactly as they do
100
+ * in the app they use today. Closing that honestly is a change on the video
101
+ * service's side, not here.
102
+ */
103
+
104
+ const props = withDefaults(
105
+ defineProps<{
106
+ camera: TWallCamera | null;
107
+ /** Nine tiles: drop the status word, keep the dot. */
108
+ dense?: boolean;
109
+ /** Browser connectivity. One dead tile and a dead browser look identical otherwise. */
110
+ online?: boolean;
111
+ focused?: boolean;
112
+ }>(),
113
+ { dense: false, online: true, focused: false }
114
+ );
115
+
116
+ defineEmits<{ (e: "focus", camera: TWallCamera): void }>();
117
+
118
+ const root = ref<HTMLElement | null>(null);
119
+ const player = ref<TPlayerState>("loading");
120
+ let timer: ReturnType<typeof setTimeout> | null = null;
121
+
122
+ const view = computed(() =>
123
+ tileView(props.camera, { online: props.online, player: player.value })
124
+ );
125
+
126
+ function onPlayerLoad() {
127
+ player.value = "ready";
128
+ clearTimer();
129
+ }
130
+
131
+ function clearTimer() {
132
+ if (timer) {
133
+ clearTimeout(timer);
134
+ timer = null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Restart the clock whenever the address changes — a tile reused for a
140
+ * different camera must not inherit the previous one's verdict, in either
141
+ * direction.
142
+ */
143
+ watch(
144
+ () => props.camera?.host,
145
+ (host) => {
146
+ clearTimer();
147
+ player.value = "loading";
148
+ if (!host) return;
149
+ timer = setTimeout(() => {
150
+ if (player.value === "loading") player.value = "timeout";
151
+ }, LIVE_CONNECT_TIMEOUT_MS);
152
+ },
153
+ { immediate: true }
154
+ );
155
+
156
+ onBeforeUnmount(clearTimer);
157
+
158
+ /** The browser's own fullscreen. Nothing to build, and it survives the F11 key. */
159
+ async function toggleFullscreen() {
160
+ const el = root.value;
161
+ if (!el) return;
162
+ try {
163
+ if (document.fullscreenElement) await document.exitFullscreen();
164
+ else await el.requestFullscreen();
165
+ } catch {
166
+ // A browser that refuses fullscreen (permissions policy, an iframe, an old
167
+ // Safari) is not an error worth interrupting a supervisor over — the tile
168
+ // keeps working exactly as it did.
169
+ }
170
+ }
171
+ </script>
172
+
173
+ <style scoped>
174
+ .vms-tile {
175
+ position: relative;
176
+ overflow: hidden;
177
+ background: var(--vms-tile, #101418);
178
+ border-radius: var(--vms-radius, 3px);
179
+ outline: none;
180
+ min-height: 0;
181
+ }
182
+
183
+ .vms-tile--focused {
184
+ box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
185
+ }
186
+
187
+ .vms-tile:focus-visible {
188
+ box-shadow: inset 0 0 0 2px var(--vms-accent, #5b9be0);
189
+ }
190
+
191
+ .vms-tile--empty {
192
+ background: transparent;
193
+ border: 1px dashed var(--vms-line, #232b36);
194
+ }
195
+
196
+ .vms-tile__frame {
197
+ width: 100%;
198
+ height: 100%;
199
+ border: none;
200
+ display: block;
201
+ background: #000;
202
+ }
203
+
204
+ .vms-tile__overlay {
205
+ position: absolute;
206
+ inset: 0;
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ padding: 10px;
211
+ background: var(--vms-scrim-solid, rgba(7, 9, 12, 0.82));
212
+ pointer-events: none;
213
+ }
214
+
215
+ .vms-tile__reason {
216
+ margin: 0;
217
+ text-align: center;
218
+ color: var(--vms-text-dim, #94a0b0);
219
+ font-size: 12px;
220
+ line-height: 16px;
221
+ max-width: 32ch;
222
+ }
223
+
224
+ .vms-tile--dense .vms-tile__reason {
225
+ font-size: 11px;
226
+ line-height: 15px;
227
+ }
228
+
229
+ .vms-tile__status {
230
+ position: absolute;
231
+ top: 6px;
232
+ right: 6px;
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 5px;
236
+ padding: 2px 6px;
237
+ border-radius: var(--vms-radius, 3px);
238
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
239
+ pointer-events: none;
240
+ }
241
+
242
+ .vms-tile__dot {
243
+ width: 7px;
244
+ height: 7px;
245
+ border-radius: 50%;
246
+ background: var(--vms-text-faint, #5c6675);
247
+ flex: none;
248
+ }
249
+
250
+ /* State colours only. The chrome itself stays achromatic on purpose. */
251
+ .vms-tile__dot--live {
252
+ background: #4caf50;
253
+ }
254
+ .vms-tile__dot--connecting {
255
+ background: #fb8c00;
256
+ }
257
+ .vms-tile__dot--no-signal,
258
+ .vms-tile__dot--offline {
259
+ background: #e0241c;
260
+ }
261
+ .vms-tile__dot--unavailable {
262
+ background: var(--vms-text-faint, #5c6675);
263
+ }
264
+
265
+ .vms-tile__state {
266
+ color: var(--vms-text, #e8ecf1);
267
+ font-size: 11px;
268
+ line-height: 14px;
269
+ white-space: nowrap;
270
+ }
271
+
272
+ .vms-tile__chrome {
273
+ position: absolute;
274
+ left: 6px;
275
+ right: 6px;
276
+ bottom: 5px;
277
+ display: flex;
278
+ pointer-events: none;
279
+ }
280
+
281
+ .vms-tile__name {
282
+ color: var(--vms-text, #e8ecf1);
283
+ font-size: 12px;
284
+ line-height: 16px;
285
+ font-weight: 600;
286
+ padding: 1px 6px;
287
+ border-radius: var(--vms-radius, 3px);
288
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
289
+ overflow: hidden;
290
+ text-overflow: ellipsis;
291
+ white-space: nowrap;
292
+ }
293
+
294
+ .vms-tile--dense .vms-tile__name {
295
+ font-size: 11px;
296
+ line-height: 15px;
297
+ }
298
+
299
+ .vms-tile__expand {
300
+ position: absolute;
301
+ top: 6px;
302
+ left: 6px;
303
+ display: flex;
304
+ align-items: center;
305
+ justify-content: center;
306
+ width: 24px;
307
+ height: 24px;
308
+ border-radius: var(--vms-radius, 3px);
309
+ background: var(--vms-scrim, rgba(7, 9, 12, 0.62));
310
+ color: var(--vms-text, #e8ecf1);
311
+ border: none;
312
+ cursor: pointer;
313
+ opacity: 0;
314
+ transition: opacity 120ms ease;
315
+ }
316
+
317
+ /* The control is always THERE; it only stops competing with the picture. */
318
+ .vms-tile:hover .vms-tile__expand,
319
+ .vms-tile:focus-within .vms-tile__expand {
320
+ opacity: 1;
321
+ }
322
+
323
+ .vms-tile__expand:focus-visible {
324
+ opacity: 1;
325
+ outline: 2px solid var(--vms-accent, #5b9be0);
326
+ }
327
+
328
+ /* Fullscreen: the picture takes the whole screen, the chrome stays legible. */
329
+ .vms-tile:fullscreen {
330
+ border-radius: 0;
331
+ background: #000;
332
+ }
333
+ </style>
@@ -99,6 +99,33 @@ export default function () {
99
99
  });
100
100
  }
101
101
 
102
+ /**
103
+ * The monitoring wall for one site.
104
+ *
105
+ * A different endpoint from `getAllSiteCameras`, and the difference is the
106
+ * point. That one is the Settings panel's paginated CRUD list; this one is
107
+ * purpose-built for a wall and is what the React Native app already uses:
108
+ *
109
+ * - it is **site-scoped and authorised server-side** — the caller must be a
110
+ * member of the site, of its owning organisation, or work for an
111
+ * organisation engaged to serve it;
112
+ * - it returns `type: "ip"` cameras only, so an ANPR unit can never land on a
113
+ * monitoring wall;
114
+ * - it returns each camera's **capability descriptor** and the server's own
115
+ * `unavailableReason`, so a tile explains itself instead of showing a black
116
+ * rectangle;
117
+ * - it never returns a credential, and it contacts no device.
118
+ *
119
+ * Unpaginated by design: a wall shows a site's cameras, and paging them was
120
+ * an artefact of borrowing the CRUD list.
121
+ */
122
+ async function getSiteWall(siteId: string) {
123
+ return await useNuxtApp().$api<Record<string, any>>(
124
+ `/api/site-cameras/site/${siteId}/wall`,
125
+ { method: "GET" }
126
+ );
127
+ }
128
+
102
129
  async function updateSiteInformation(
103
130
  siteId: string,
104
131
  payload: { bgImage: string; description: string; docs: { id: string; name: string }[] }
@@ -175,6 +202,7 @@ export default function () {
175
202
  updateSite,
176
203
  addCamera,
177
204
  getAllSiteCameras,
205
+ getSiteWall,
178
206
  setSiteGuardPosts,
179
207
  updateSiteCamera,
180
208
  deleteSiteCameraById,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.1.4-staging.54",
5
+ "version": "3.1.4-staging.56",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -0,0 +1,149 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import {
5
+ capabilityView,
6
+ isDenseLayout,
7
+ tileView,
8
+ wallCameras,
9
+ wallLayout,
10
+ wallTiles,
11
+ } from "./camera-wall.ts";
12
+
13
+ const ip = (over: Record<string, unknown> = {}) => ({
14
+ _id: "c1",
15
+ name: "GYM AREA",
16
+ type: "ip",
17
+ status: "active",
18
+ host: "https://example.invalid/4",
19
+ ...over,
20
+ });
21
+
22
+ /* ---------------------------------------------------------------- rule 1 */
23
+
24
+ test("an ANPR camera can never reach the wall", () => {
25
+ const items = [ip(), { _id: "c2", type: "anpr", host: "http://example.invalid:1234" }];
26
+ const kept = wallCameras(items);
27
+ assert.equal(kept.length, 1);
28
+ assert.equal(kept[0]._id, "c1");
29
+ });
30
+
31
+ test("a junk response is an empty wall, not a crash", () => {
32
+ assert.deepEqual(wallCameras(undefined), []);
33
+ assert.deepEqual(wallCameras([null, 3, "x"]), []);
34
+ });
35
+
36
+ /* ---------------------------------------------------------- layout + order */
37
+
38
+ test("an unknown layout key falls back to four tiles", () => {
39
+ assert.equal(wallLayout("7x7").tiles, 4);
40
+ assert.equal(isDenseLayout("3x3"), true);
41
+ assert.equal(isDenseLayout("2x2"), false);
42
+ });
43
+
44
+ test("no selection means the site's own order, not a blank wall", () => {
45
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
46
+ const tiles = wallTiles(cams, [], "2x2");
47
+ assert.equal(tiles.length, 4);
48
+ assert.equal(tiles[0]._id, "a");
49
+ assert.equal(tiles[1]._id, "b");
50
+ assert.equal(tiles[2], null);
51
+ });
52
+
53
+ test("a selection sets the order, and a stale id is dropped rather than drawn", () => {
54
+ const cams = [ip({ _id: "a" }), ip({ _id: "b" })];
55
+ const tiles = wallTiles(cams, ["b", "gone", "a"], "1x1");
56
+ assert.equal(tiles.length, 1);
57
+ assert.equal(tiles[0]._id, "b");
58
+ });
59
+
60
+ /* ---------------------------------------------------------------- rule 2/3 */
61
+
62
+ test("no descriptor at all counts as NOT available", () => {
63
+ const v = capabilityView(ip(), "ptz");
64
+ assert.equal(v.available, false);
65
+ assert.equal(v.tag, "Not checked yet");
66
+ assert.match(v.detail, /has not been asked/);
67
+ });
68
+
69
+ test("the server's own sentence is shown, never rewritten", () => {
70
+ const cam = ip({
71
+ capabilities: {
72
+ playback: {
73
+ state: "unsupported",
74
+ detail: "No direct connection to this camera's recorder is configured on this server.",
75
+ },
76
+ },
77
+ });
78
+ const v = capabilityView(cam, "playback");
79
+ assert.equal(v.tag, "Not set up yet");
80
+ assert.equal(
81
+ v.detail,
82
+ "No direct connection to this camera's recorder is configured on this server."
83
+ );
84
+ });
85
+
86
+ test("a supported capability wears no tag", () => {
87
+ const cam = ip({ capabilities: { playback: { state: "supported", detail: null } } });
88
+ assert.equal(capabilityView(cam, "playback").available, true);
89
+ assert.equal(capabilityView(cam, "playback").tag, null);
90
+ });
91
+
92
+ /* ------------------------------------------------------------------ rule 4 */
93
+
94
+ test("a page that has not loaded is Connecting, never Live", () => {
95
+ assert.equal(tileView(ip(), { player: "loading" }).state, "connecting");
96
+ assert.equal(tileView(ip(), { player: "ready" }).label, "Live");
97
+ assert.equal(tileView(ip(), { player: "timeout" }).state, "no-signal");
98
+ });
99
+
100
+ test("the browser being offline outranks everything and says so differently", () => {
101
+ assert.equal(tileView(ip(), { online: false, player: "ready" }).label, "No connection");
102
+ assert.equal(tileView(ip(), { player: "timeout" }).label, "No signal");
103
+ });
104
+
105
+ test("an inactive camera, a camera with no address, and an empty tile all explain themselves", () => {
106
+ assert.match(tileView(ip({ status: "inactive" })).detail, /not active/);
107
+ assert.match(tileView(ip({ host: "" })).detail, /no address/);
108
+ assert.match(tileView(null).detail, /No camera in this position/);
109
+ assert.equal(tileView(ip({ host: "" })).showPlayer, false);
110
+ });
111
+
112
+ test("a server that says live video is unsupported is believed", () => {
113
+ const cam = ip({
114
+ capabilities: {
115
+ liveVideo: { state: "unsupported", detail: "This camera is not active." },
116
+ },
117
+ });
118
+ const v = tileView(cam, { player: "ready" });
119
+ assert.equal(v.showPlayer, false);
120
+ assert.equal(v.detail, "This camera is not active.");
121
+ });
122
+
123
+ /* ------------------------------------------- the server's reason wins */
124
+
125
+ test("the server's own refusal is shown verbatim, not replaced with a guess", () => {
126
+ // The one that matters: the browser cannot know this, and until the API host
127
+ // is configured it is the true answer for every camera in the estate.
128
+ const noRecorder = "No recorder is configured for this camera's relay.";
129
+ const view = tileView(ip({ unavailableReason: noRecorder }));
130
+ assert.equal(view.detail, noRecorder);
131
+ assert.equal(view.state, "unavailable");
132
+ assert.equal(view.showPlayer, false);
133
+ });
134
+
135
+ test("the server's reason beats the reasons the wall could work out itself", () => {
136
+ const reason = "This camera has no address configured.";
137
+ assert.equal(tileView(ip({ status: "inactive", unavailableReason: reason })).detail, reason);
138
+ });
139
+
140
+ test("an absent or blank reason changes nothing", () => {
141
+ assert.equal(tileView(ip({ unavailableReason: null })).state, "connecting");
142
+ assert.equal(tileView(ip({ unavailableReason: " " })).state, "connecting");
143
+ assert.equal(tileView(ip()).state, "connecting");
144
+ });
145
+
146
+ test("being offline still explains every tile at once, ahead of the server's reason", () => {
147
+ const view = tileView(ip({ unavailableReason: "Something server-side." }), { online: false });
148
+ assert.equal(view.state, "offline");
149
+ });