@7365admin1/layer-common 3.2.1-staging.65 → 3.2.1-staging.67
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.
|
@@ -66,6 +66,29 @@
|
|
|
66
66
|
</template>
|
|
67
67
|
</v-snackbar>
|
|
68
68
|
|
|
69
|
+
<!--
|
|
70
|
+
Camera health is its own alert, deliberately separate from the plate
|
|
71
|
+
snackbar above: it is not a visitor event, it must not be silenced by the
|
|
72
|
+
unregistered-visitor switch, and it must not be hidden on the unregistered
|
|
73
|
+
tab. Several failing cameras collapse into one line rather than one
|
|
74
|
+
snackbar each.
|
|
75
|
+
-->
|
|
76
|
+
<v-snackbar
|
|
77
|
+
v-if="canViewVisitor"
|
|
78
|
+
v-model="cameraAlert.modal"
|
|
79
|
+
:color="cameraFaults.size ? 'warning' : 'success'"
|
|
80
|
+
:timeout="10000"
|
|
81
|
+
location="top right"
|
|
82
|
+
class="rounded-xl"
|
|
83
|
+
multi-line
|
|
84
|
+
:z-index="100000000"
|
|
85
|
+
>
|
|
86
|
+
<span class="text-1-4rem">{{ cameraAlertText }}</span>
|
|
87
|
+
<template #actions>
|
|
88
|
+
<v-btn icon="mdi-close" variant="text" @click="cameraAlert.modal = false" />
|
|
89
|
+
</template>
|
|
90
|
+
</v-snackbar>
|
|
91
|
+
|
|
69
92
|
<v-dialog v-model="permanentMessageDialog.modal" max-width="480" persistent>
|
|
70
93
|
<v-card>
|
|
71
94
|
<v-card-title class="text-h6">Notice</v-card-title>
|
|
@@ -132,6 +155,36 @@ const permanentMessageDialog = reactive({
|
|
|
132
155
|
text: "",
|
|
133
156
|
});
|
|
134
157
|
|
|
158
|
+
// Outstanding camera faults for this site, keyed by camera id so a repeat for
|
|
159
|
+
// the same camera replaces its line instead of adding another.
|
|
160
|
+
const cameraFaults = reactive(new Map<string, string>());
|
|
161
|
+
const cameraRecovery = ref("");
|
|
162
|
+
const cameraAlert = reactive({ modal: false });
|
|
163
|
+
|
|
164
|
+
const cameraAlertText = computed(
|
|
165
|
+
() => cameraAlertMessage([...cameraFaults.values()]) || cameraRecovery.value
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const handleCameraHealth = (data: TVisitorSocketData) => {
|
|
169
|
+
// Older API-core builds send the fault text with no `event`, so a bare
|
|
170
|
+
// message is a fault.
|
|
171
|
+
if (data?.event === "camera-recovered") {
|
|
172
|
+
// Without a camera id there is no way to tell which fault cleared.
|
|
173
|
+
if (data?.camera) cameraFaults.delete(data.camera);
|
|
174
|
+
else cameraFaults.clear();
|
|
175
|
+
// Alerts are now infrequent, so the operator is told it came back —
|
|
176
|
+
// otherwise the only signal a camera recovered is silence.
|
|
177
|
+
cameraRecovery.value = cameraFaults.size ? "" : data?.message || "";
|
|
178
|
+
cameraAlert.modal = Boolean(cameraAlertText.value);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!data?.message) return;
|
|
183
|
+
cameraRecovery.value = "";
|
|
184
|
+
cameraFaults.set(data?.camera || data.message, data.message);
|
|
185
|
+
cameraAlert.modal = true;
|
|
186
|
+
};
|
|
187
|
+
|
|
135
188
|
watch(
|
|
136
189
|
() => props.siteId,
|
|
137
190
|
async (siteId) => {
|
|
@@ -233,8 +286,9 @@ const connectSocket = () => {
|
|
|
233
286
|
}
|
|
234
287
|
|
|
235
288
|
|
|
236
|
-
|
|
237
|
-
|
|
289
|
+
// Camera health, not a visitor event: its own alert, its own gate.
|
|
290
|
+
if (data?.message || data?.event) {
|
|
291
|
+
handleCameraHealth(data);
|
|
238
292
|
}
|
|
239
293
|
|
|
240
294
|
if (data?.messagePermanent) {
|
|
@@ -5,6 +5,32 @@ export type TVisitorSocketData = {
|
|
|
5
5
|
message?: string;
|
|
6
6
|
messagePermanent?: string;
|
|
7
7
|
reload?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Camera health. Sent alongside `message` by API-core so the client can group
|
|
10
|
+
* or clear an alert per camera instead of matching on the message text.
|
|
11
|
+
* Absent on older API-core builds — treat a bare `message` as a fault.
|
|
12
|
+
*/
|
|
13
|
+
event?: "camera-fault" | "camera-recovered";
|
|
14
|
+
/** The camera the `event` is about. */
|
|
15
|
+
camera?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One line for however many cameras are currently down.
|
|
20
|
+
*
|
|
21
|
+
* A site with several ANPR cameras could otherwise raise one alert per camera
|
|
22
|
+
* at the same moment. The single-camera case shows the server's own message,
|
|
23
|
+
* which names the camera; beyond that, naming them all in a snackbar is worse
|
|
24
|
+
* than sending the operator to the page that lists them.
|
|
25
|
+
*/
|
|
26
|
+
export const cameraAlertMessage = (messages: string[]): string => {
|
|
27
|
+
if (!messages.length) return "";
|
|
28
|
+
if (messages.length === 1) return messages[0] as string;
|
|
29
|
+
return (
|
|
30
|
+
`${messages.length} cameras at this site are not responding. Plate reads ` +
|
|
31
|
+
`and automatic barrier opening are stopped for them. Open Site Settings > ` +
|
|
32
|
+
`Cameras to see which.`
|
|
33
|
+
);
|
|
8
34
|
};
|
|
9
35
|
|
|
10
36
|
export const useVisitorSocket = () => {
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "3.2.1-staging.
|
|
5
|
+
"version": "3.2.1-staging.67",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"publishConfig": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"build": "nuxt build .playground",
|
|
15
15
|
"generate": "nuxt generate .playground",
|
|
16
16
|
"preview": "nuxt preview .playground",
|
|
17
|
+
"test": "esbuild composables/useVisitorSocket.ts --format=esm --outfile=test/.build/useVisitorSocket.mjs --log-level=error && node --test \"test/*.test.mjs\"",
|
|
17
18
|
"release": "yarn run build && changeset publish"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
package/plugins/vuetify.ts
CHANGED
|
@@ -42,6 +42,67 @@ import {
|
|
|
42
42
|
} from "vuetify-pro-tiptap";
|
|
43
43
|
import "vuetify-pro-tiptap/style.css";
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Contrast decisions live here rather than inline, because both themes need the
|
|
47
|
+
* same two opacity corrections and a value that differs by accident between
|
|
48
|
+
* light and dark is a bug nobody sees until somebody flips the switch.
|
|
49
|
+
*
|
|
50
|
+
* `disabled-opacity`: Material's 0.38 measures 2.68:1 on white, below the 4.5:1
|
|
51
|
+
* a sentence needs, and disabled fields in this product carry sentences people
|
|
52
|
+
* are expected to read. 0.55 is 4.74:1 on white and 6.21:1 on the dark page -
|
|
53
|
+
* the same value the camera wall settled on, for the same reason.
|
|
54
|
+
*
|
|
55
|
+
* `border-opacity`: 0.12 measures 1.32:1, which is a divider you cannot see.
|
|
56
|
+
* Dark goes to 0.35 (3.22:1) and carries the boundary properly. Light goes to
|
|
57
|
+
* 0.26 (1.88:1) - visible, but short of the 3:1 a component boundary wants.
|
|
58
|
+
* Reaching it on white needs 0.42, which turns every table and card edge into a
|
|
59
|
+
* mid-grey grid across eleven applications; that is a change somebody should
|
|
60
|
+
* look at before it ships, not one to slip in behind a contrast fix.
|
|
61
|
+
*/
|
|
62
|
+
const LIGHT_THEME = {
|
|
63
|
+
dark: false,
|
|
64
|
+
colors: {
|
|
65
|
+
primary: "#042134",
|
|
66
|
+
"primary-button": "#1867C0",
|
|
67
|
+
"text-primary": "#052439",
|
|
68
|
+
},
|
|
69
|
+
variables: {
|
|
70
|
+
"disabled-opacity": 0.55,
|
|
71
|
+
"border-opacity": 0.26,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Only the keys that must differ; Vuetify merges the rest from its stock dark.
|
|
77
|
+
*/
|
|
78
|
+
const DARK_THEME = {
|
|
79
|
+
dark: true,
|
|
80
|
+
colors: {
|
|
81
|
+
/**
|
|
82
|
+
* Stock dark's #121212 page and #212121 surface measure 1.16:1 apart, so a
|
|
83
|
+
* card, a dialog and the page behind them were one flat sheet. These are
|
|
84
|
+
* 1.19:1 - elevation on a dark screen is a genuinely small delta - but they
|
|
85
|
+
* are tinted towards the brand navy rather than neutral grey, and the
|
|
86
|
+
* divider opacity above is what actually draws the edges.
|
|
87
|
+
*/
|
|
88
|
+
background: "#0E1319",
|
|
89
|
+
surface: "#1B242F",
|
|
90
|
+
/** Stock dark ships a lavender #ccbfd6 here, which is not our product. */
|
|
91
|
+
"surface-bright": "#26313E",
|
|
92
|
+
"surface-light": "#26313E",
|
|
93
|
+
/** The brand navy lifted until the nav drawer is a surface, not a hole. */
|
|
94
|
+
primary: "#17506F",
|
|
95
|
+
/** Unchanged on purpose: it already passes in both themes. */
|
|
96
|
+
"primary-button": "#1867C0",
|
|
97
|
+
/** #052439 on a dark page measures 1.18:1. This is 15.72:1. */
|
|
98
|
+
"text-primary": "#E8ECF1",
|
|
99
|
+
},
|
|
100
|
+
variables: {
|
|
101
|
+
"disabled-opacity": 0.55,
|
|
102
|
+
"border-opacity": 0.35,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
45
106
|
export default defineNuxtPlugin((app) => {
|
|
46
107
|
const vuetify = createVuetify({
|
|
47
108
|
defaults: {
|
|
@@ -69,17 +130,53 @@ export default defineNuxtPlugin((app) => {
|
|
|
69
130
|
// hint: "This field is required",
|
|
70
131
|
// },
|
|
71
132
|
},
|
|
133
|
+
/**
|
|
134
|
+
* THE PRODUCT HAS TWO THEMES, AND UNTIL NOW ONLY ONE OF THEM WAS OURS.
|
|
135
|
+
*
|
|
136
|
+
* Every app shows a light/dark toggle in the app bar (Layout/Header.vue),
|
|
137
|
+
* and it sets Vuetify's built-in "light" and "dark" themes. Those were left
|
|
138
|
+
* at Vuetify's factory values, so one click on any screen in any of the
|
|
139
|
+
* eleven apps threw the brand away: `primary` stopped being the Seven365
|
|
140
|
+
* navy and became Material blue #2196F3 - and `primary` is the navigation
|
|
141
|
+
* drawer's fill - while `primary-button` and `text-primary`, used in around
|
|
142
|
+
* eighty places across the layer and the apps, do not exist in a stock
|
|
143
|
+
* theme at all, so those components lost their colour to an undefined CSS
|
|
144
|
+
* variable. There was also no way back to the brand: the toggle only ever
|
|
145
|
+
* flipped between the two stock themes.
|
|
146
|
+
*
|
|
147
|
+
* The fix is to make the stock names OURS rather than to add new names.
|
|
148
|
+
* Several components already branch on `theme.global.name === "dark"` (this
|
|
149
|
+
* layer's FormDialog, property-management's facility icons), so a
|
|
150
|
+
* differently-named dark theme would have left every one of them silently
|
|
151
|
+
* on their light branch inside a dark app. Overriding `light` and `dark`
|
|
152
|
+
* means the toggle, `v-theme-provider theme="light"`, the `plain-dark`
|
|
153
|
+
* layout and all of those comparisons keep working, and start being right.
|
|
154
|
+
*
|
|
155
|
+
* Measured, not eyeballed - WCAG 2.1, alpha composited against the surface
|
|
156
|
+
* each value actually sits on:
|
|
157
|
+
*
|
|
158
|
+
* text-primary on the page dark 1.18:1 -> 15.72:1
|
|
159
|
+
* nav drawer fill vs the page dark 1.13:1 -> 2.14:1 (+ a real border)
|
|
160
|
+
* white label in the nav drawer dark 3.12:1 -> 8.70:1
|
|
161
|
+
* primary-button vs the page dark 3.34:1 -> 3.32:1 (unchanged)
|
|
162
|
+
* white label on that button both 5.61:1 -> 5.61:1 (unchanged)
|
|
163
|
+
* disabled text light 2.68:1 -> 4.74:1
|
|
164
|
+
* disabled text dark 3.57:1 -> 6.21:1
|
|
165
|
+
* divider / outline dark 1.38:1 -> 3.22:1
|
|
166
|
+
* divider / outline light 1.32:1 -> 1.88:1
|
|
167
|
+
*/
|
|
72
168
|
theme: {
|
|
73
|
-
defaultTheme: "
|
|
169
|
+
defaultTheme: "light",
|
|
74
170
|
themes: {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
171
|
+
light: LIGHT_THEME,
|
|
172
|
+
dark: DARK_THEME,
|
|
173
|
+
/**
|
|
174
|
+
* The name this product booted on before the two above carried the
|
|
175
|
+
* brand. Kept, and identical to `light`, so anything that asks for it
|
|
176
|
+
* by name - in an app that is not in this repository - still gets a
|
|
177
|
+
* theme rather than a blank one.
|
|
178
|
+
*/
|
|
179
|
+
iservice365: LIGHT_THEME,
|
|
83
180
|
},
|
|
84
181
|
},
|
|
85
182
|
icons: {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { cameraAlertMessage } from "./.build/useVisitorSocket.mjs";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The reported symptom was one camera raising a red toast every 10 seconds on
|
|
8
|
+
* an unrelated page. API-core now paces the alert; this side has to make sure
|
|
9
|
+
* several cameras failing at once still cannot become several snackbars.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
test("nothing is shown when no camera is down", () => {
|
|
13
|
+
assert.equal(cameraAlertMessage([]), "");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("one failing camera shows the server's own message, which names it", () => {
|
|
17
|
+
const text = "The entry and exit ANPR camera at Seventh Condominium is not responding.";
|
|
18
|
+
assert.equal(cameraAlertMessage([text]), text);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("several failing cameras collapse into a single line", () => {
|
|
22
|
+
const many = ["camera one is down", "camera two is down", "camera three is down"];
|
|
23
|
+
const text = cameraAlertMessage(many);
|
|
24
|
+
assert.match(text, /^3 cameras at this site are not responding/);
|
|
25
|
+
for (const message of many) assert.doesNotMatch(text, new RegExp(message));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("the collapsed line points at the page that lists them", () => {
|
|
29
|
+
assert.match(cameraAlertMessage(["a", "b"]), /Site Settings > Cameras/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("no camera message tells the operator to deactivate anything", () => {
|
|
33
|
+
for (const messages of [["a camera is down"], ["a", "b"]]) {
|
|
34
|
+
assert.doesNotMatch(cameraAlertMessage(messages), /inactive|deactivat/i);
|
|
35
|
+
}
|
|
36
|
+
});
|