@7365admin1/layer-common 4.140.0 → 4.142.0
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 +16 -4
- package/components/ManpowerSiteSwitch.vue +70 -0
- package/components/SiteSettings.vue +5 -0
- package/composables/useSiteFeatureSettings.ts +1 -0
- package/nuxt.config.ts +4 -0
- package/package.json +1 -1
- package/plugins/API.ts +31 -1
- package/plugins/client-logs.client.ts +47 -0
- package/utils/client-log.ts +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.142.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 38e504d: Every app now reports browser crashes, failed API calls and successful saves to the Seven365 Logger through API-core (POST /api/client-logs), filed under the app's own folder.
|
|
8
|
+
|
|
9
|
+
## 4.141.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- a8e8548: Site Settings: "Manpower" panel (Seven365 Super Admin only) to switch Manpower on or off for a site (site feature setting `manpower`). Drawn only when the host passes `show-manpower-switch` (Security app). Not set = as before (on only at the legacy Aigle Security Command Centre site).
|
|
14
|
+
|
|
3
15
|
## 4.140.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -3395,11 +3407,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3395
3407
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3396
3408
|
own rule from two endpoints the console already calls, unprojected:
|
|
3397
3409
|
|
|
3398
|
-
|
|
3399
|
-
|
|
3410
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3411
|
+
GET /api/roles/id/:role that membership's role document
|
|
3400
3412
|
|
|
3401
|
-
|
|
3402
|
-
|
|
3413
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3414
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3403
3415
|
|
|
3404
3416
|
`role.default` is the marker because it is the only property of a platform
|
|
3405
3417
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Seven365 Super Admin only: everyone else never sees Manpower set-up. -->
|
|
3
|
+
<v-expansion-panel v-if="isStaff" data-test="manpower-site-switch">
|
|
4
|
+
<v-expansion-panel-title>
|
|
5
|
+
<v-icon class="mr-2">mdi-chart-bar</v-icon>
|
|
6
|
+
Manpower
|
|
7
|
+
</v-expansion-panel-title>
|
|
8
|
+
<v-expansion-panel-text>
|
|
9
|
+
<v-switch v-model="enabled" label="Show Manpower at this site" color="primary" hide-details
|
|
10
|
+
data-test="manpower-site-switch-toggle" />
|
|
11
|
+
<p class="text-caption text-medium-emphasis mt-1 mb-3">
|
|
12
|
+
Manpower (HRMLabs staff attendance) is for security agencies only. When on, the agency's managers see
|
|
13
|
+
Manpower in the menu and the guard app at this site. Only Seven365 can switch this on or off.
|
|
14
|
+
Now: <strong>{{ savedOn ? "On" : "Off" }}</strong>{{ saved ? "" : " (not set - default)" }}.
|
|
15
|
+
</p>
|
|
16
|
+
<v-btn class="screen-btn-primary" color="primary-button" variant="flat" text="Save"
|
|
17
|
+
:loading="saving" :disabled="!loaded" data-test="manpower-site-switch-save" @click="save" />
|
|
18
|
+
<Snackbar v-model="toast.show" :text="toast.message" :color="toast.color" />
|
|
19
|
+
</v-expansion-panel-text>
|
|
20
|
+
</v-expansion-panel>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import useSiteFeatureSettings from "../composables/useSiteFeatureSettings";
|
|
25
|
+
import useConsoleTier from "../composables/useConsoleTier";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Site setting `metadata.manpower` (core site-feature-settings, owner
|
|
29
|
+
* 2026-10-21). Not set = as before: on only at the one site Manpower was
|
|
30
|
+
* pinned to (core `MANPOWER_LEGACY_SITE_ID`), off everywhere else. The server
|
|
31
|
+
* refuses a change by anyone but the Seven365 Super Admin (403) and enforces
|
|
32
|
+
* the switch on every Manpower route; this only draws the switch.
|
|
33
|
+
*/
|
|
34
|
+
const LEGACY_SITE_ID = "6a4496f47521150c58977c68";
|
|
35
|
+
|
|
36
|
+
const props = defineProps({
|
|
37
|
+
siteId: { type: String, required: true },
|
|
38
|
+
saved: { type: Object as PropType<{ enabled?: boolean } | null>, default: null },
|
|
39
|
+
/** The site has been read; Save stays off until then. */
|
|
40
|
+
loaded: { type: Boolean, default: false },
|
|
41
|
+
});
|
|
42
|
+
const emit = defineEmits(["saved"]);
|
|
43
|
+
|
|
44
|
+
const { isStaff, load: loadTier } = useConsoleTier();
|
|
45
|
+
onMounted(() => loadTier());
|
|
46
|
+
|
|
47
|
+
const savedOn = computed(() =>
|
|
48
|
+
typeof props.saved?.enabled === "boolean" ? props.saved.enabled : props.siteId === LEGACY_SITE_ID,
|
|
49
|
+
);
|
|
50
|
+
const enabled = ref(false);
|
|
51
|
+
watch(savedOn, (v) => (enabled.value = v), { immediate: true });
|
|
52
|
+
|
|
53
|
+
const { saveSiteFeature } = useSiteFeatureSettings();
|
|
54
|
+
const saving = ref(false);
|
|
55
|
+
const toast = reactive({ show: false, message: "", color: "" });
|
|
56
|
+
|
|
57
|
+
async function save() {
|
|
58
|
+
if (!props.loaded || !isStaff.value) return;
|
|
59
|
+
saving.value = true;
|
|
60
|
+
try {
|
|
61
|
+
await saveSiteFeature(props.siteId, "manpower", { enabled: enabled.value });
|
|
62
|
+
Object.assign(toast, { show: true, message: "Saved.", color: "success" });
|
|
63
|
+
emit("saved");
|
|
64
|
+
} catch (e: any) {
|
|
65
|
+
Object.assign(toast, { show: true, message: e?.data?.message || "Could not save.", color: "error" });
|
|
66
|
+
} finally {
|
|
67
|
+
saving.value = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
@@ -267,6 +267,9 @@
|
|
|
267
267
|
</v-expansion-panel-text>
|
|
268
268
|
</v-expansion-panel>
|
|
269
269
|
|
|
270
|
+
<ManpowerSiteSwitch v-if="showManpowerSwitch" :site-id="siteId"
|
|
271
|
+
:saved="(siteData as any)?.metadata?.manpower" :loaded="!!siteData" @saved="refreshSiteData" />
|
|
272
|
+
|
|
270
273
|
<slot
|
|
271
274
|
name="panels"
|
|
272
275
|
:can-manage="canManageSiteSettings"
|
|
@@ -296,6 +299,8 @@ const props = defineProps({
|
|
|
296
299
|
showResidentVisitors: { type: Boolean, default: false },
|
|
297
300
|
/** Visitor registration, Passes & keys and Incident report emails: PM and Security only. */
|
|
298
301
|
showSiteSwitches: { type: Boolean, default: false },
|
|
302
|
+
/** Manpower per-site switch (Seven365 Super Admin only): the Security host only. */
|
|
303
|
+
showManpowerSwitch: { type: Boolean, default: false },
|
|
299
304
|
});
|
|
300
305
|
|
|
301
306
|
const openPanels = ref([]); // default open first
|
package/nuxt.config.ts
CHANGED
|
@@ -17,6 +17,10 @@ export default defineNuxtConfig({
|
|
|
17
17
|
...(process.env.API_CORE
|
|
18
18
|
? { "/api/storage/**": { proxy: `${process.env.API_CORE}/api/files/object/**` } }
|
|
19
19
|
: {}),
|
|
20
|
+
// Browser logs (utils/client-log.ts), forwarded by API-core to the Seven365 Logger.
|
|
21
|
+
...(process.env.API_CORE
|
|
22
|
+
? { "/api/client-logs": { proxy: `${process.env.API_CORE}/api/client-logs` } }
|
|
23
|
+
: {}),
|
|
20
24
|
},
|
|
21
25
|
build: {
|
|
22
26
|
transpile: ["vuetify"],
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.142.0",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/plugins/API.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { isPermissionRefusal } from "../utils/permission-refusal";
|
|
2
2
|
import { accessSuspendedMessage, SIGNIN_NOTICE_COOKIE } from "../utils/access-suspended";
|
|
3
|
+
import { apiPath, clientLog } from "../utils/client-log";
|
|
4
|
+
|
|
5
|
+
// Expected answers, not failures: the session lapsed, no permission, nothing there.
|
|
6
|
+
const UNLOGGED_REFUSALS = new Set([401, 403, 404]);
|
|
3
7
|
|
|
4
8
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
5
9
|
const { cookieConfig } = useRuntimeConfig().public;
|
|
@@ -33,7 +37,33 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
33
37
|
// Both refusal shapes count: API-core's 403 + PERMISSION_DENIED and core's
|
|
34
38
|
// 401 + "You do not have permission ..." (vehicle writes and every other
|
|
35
39
|
// `requireSiteReach` gate). See utils/permission-refusal.ts.
|
|
36
|
-
|
|
40
|
+
// Saves that went through, so a client's "I clicked save" can be checked.
|
|
41
|
+
onResponse({ request, options, response }) {
|
|
42
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
43
|
+
if (method === "GET" || !response.ok) return;
|
|
44
|
+
clientLog("info", `Saved: ${method} ${apiPath(String(request))}`, { status: response.status });
|
|
45
|
+
},
|
|
46
|
+
// Offline or the API unreachable: the click did nothing for the client.
|
|
47
|
+
onRequestError({ request, options, error }) {
|
|
48
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
49
|
+
clientLog("error", `Request failed: ${method} ${apiPath(String(request))}: ${error?.message ?? "network error"}`, {
|
|
50
|
+
method,
|
|
51
|
+
route: apiPath(String(request)),
|
|
52
|
+
status: 0,
|
|
53
|
+
expected: !navigator.onLine || undefined,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
onResponseError({ request, options, response }) {
|
|
57
|
+
const status = response?.status ?? 0;
|
|
58
|
+
if (!UNLOGGED_REFUSALS.has(status)) {
|
|
59
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
60
|
+
const reason = (response as any)?._data?.message;
|
|
61
|
+
clientLog(status >= 500 ? "error" : "warn", `${method} ${apiPath(String(request))} answered ${status}${reason ? `: ${reason}` : ""}`, {
|
|
62
|
+
method,
|
|
63
|
+
route: apiPath(String(request)),
|
|
64
|
+
status,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
37
67
|
// Every membership suspended (2026-10-01): the server already ended the
|
|
38
68
|
// session. Sign out here and let the sign-in page say why, once.
|
|
39
69
|
const suspended = accessSuspendedMessage((response as any)?._data);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { clientLog, configureClientLog, flushClientLogs } from "../utils/client-log";
|
|
2
|
+
|
|
3
|
+
// Crashes anywhere on the page go to the Seven365 Logger. Failed and successful
|
|
4
|
+
// API calls are logged from plugins/API.ts, where every request passes.
|
|
5
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
6
|
+
configureClientLog(String(useRuntimeConfig().public.APP ?? ""));
|
|
7
|
+
|
|
8
|
+
const describe = (error: unknown) => {
|
|
9
|
+
const e = error as { message?: string; stack?: string; name?: string } | undefined;
|
|
10
|
+
return { message: e?.message || String(error), stack: e?.stack, name: e?.name };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// An old tab asking for a file the last deploy replaced: the page needs a
|
|
14
|
+
// reload, the code is not broken. Logged, never ticketed.
|
|
15
|
+
const isStaleBuild = (message: string) =>
|
|
16
|
+
/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message);
|
|
17
|
+
|
|
18
|
+
nuxtApp.hook("vue:error", (error, _instance, info) => {
|
|
19
|
+
const { message, stack } = describe(error);
|
|
20
|
+
clientLog("error", message, { stack, source: `vue:${info}`, expected: isStaleBuild(message) || undefined });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
nuxtApp.hook("app:error", (error) => {
|
|
24
|
+
const { message, stack } = describe(error);
|
|
25
|
+
clientLog("error", message, { stack, source: "app", expected: isStaleBuild(message) || undefined });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
window.addEventListener("error", (event) => {
|
|
29
|
+
if (!event.message) return;
|
|
30
|
+
clientLog("error", event.message, {
|
|
31
|
+
stack: (event.error as Error | undefined)?.stack,
|
|
32
|
+
source: `${event.filename}:${event.lineno}:${event.colno}`,
|
|
33
|
+
expected: isStaleBuild(event.message) || undefined,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
38
|
+
const { message, stack, name } = describe(event.reason);
|
|
39
|
+
// Failed $api calls are already logged with their status by plugins/API.ts.
|
|
40
|
+
if (name === "FetchError") return;
|
|
41
|
+
clientLog("error", message, { stack, source: "promise", expected: isStaleBuild(message) || undefined });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
document.addEventListener("visibilitychange", () => {
|
|
45
|
+
if (document.visibilityState === "hidden") void flushClientLogs();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Browser logs for the Seven365 Logger. Entries are batched and sent to API-core
|
|
2
|
+
// (POST /api/client-logs), which files them under this app's own folder and adds
|
|
3
|
+
// the signed-in user from the session. Nothing is sent in development.
|
|
4
|
+
type Level = "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
type Entry = { level: Level; message: string; context: Record<string, unknown>; timestamp: string };
|
|
7
|
+
|
|
8
|
+
const queue: Entry[] = [];
|
|
9
|
+
const recent = new Map<string, number>();
|
|
10
|
+
const sessionId = Math.random().toString(36).slice(2, 12);
|
|
11
|
+
let app = "";
|
|
12
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
13
|
+
|
|
14
|
+
const BATCH = 20;
|
|
15
|
+
const FLUSH_MS = 5000;
|
|
16
|
+
const REPEAT_WINDOW_MS = 10000;
|
|
17
|
+
|
|
18
|
+
export function configureClientLog(appName: string) {
|
|
19
|
+
app = appName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function apiPath(url: string) {
|
|
23
|
+
return String(url)
|
|
24
|
+
.split("?")[0]
|
|
25
|
+
.replace(/\b[0-9a-f]{24}\b/gi, ":id")
|
|
26
|
+
.replace(/\/\d+(?=\/|$)/g, "/:n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sid() {
|
|
30
|
+
const match = document.cookie.match(/(?:^|;\s*)sid=([^;]+)/);
|
|
31
|
+
return match ? decodeURIComponent(match[1]) : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clientLog(level: Level, message: string, context: Record<string, unknown> = {}) {
|
|
35
|
+
if (!import.meta.client || import.meta.dev || !app || !message) return;
|
|
36
|
+
|
|
37
|
+
// The same line from the same tab within 10s is one event (a re-rendering
|
|
38
|
+
// component can throw the same error dozens of times a second).
|
|
39
|
+
const key = `${level}|${message}`;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if ((recent.get(key) ?? 0) > now - REPEAT_WINDOW_MS) return;
|
|
42
|
+
if (recent.size > 200) recent.clear();
|
|
43
|
+
recent.set(key, now);
|
|
44
|
+
|
|
45
|
+
queue.push({
|
|
46
|
+
level,
|
|
47
|
+
message: String(message).slice(0, 2000),
|
|
48
|
+
context: { ...context, page: location.pathname, sessionId },
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (queue.length >= BATCH) void flushClientLogs();
|
|
53
|
+
else if (!timer) timer = setTimeout(() => void flushClientLogs(), FLUSH_MS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function flushClientLogs() {
|
|
57
|
+
if (timer) clearTimeout(timer);
|
|
58
|
+
timer = null;
|
|
59
|
+
const logs = queue.splice(0, 50);
|
|
60
|
+
if (!logs.length) return;
|
|
61
|
+
try {
|
|
62
|
+
await fetch("/api/client-logs", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
keepalive: true,
|
|
65
|
+
headers: { "Content-Type": "application/json", Authorization: sid() },
|
|
66
|
+
body: JSON.stringify({ app, logs }),
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
// Logging must never break the page; a lost batch is acceptable.
|
|
70
|
+
}
|
|
71
|
+
}
|