@hostlink/nuxt-light 1.77.2 → 1.78.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/README.md +35 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/L/Menu.vue +29 -4
- package/dist/runtime/composables/useLightClientAvailability.d.ts +30 -0
- package/dist/runtime/composables/useLightClientAvailability.js +88 -0
- package/dist/runtime/plugin.js +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,9 @@ export default defineNuxtConfig({
|
|
|
79
79
|
infra: {
|
|
80
80
|
baseURL: 'https://infra.example.com/graphql',
|
|
81
81
|
audience: 'infra-api',
|
|
82
|
+
healthURL: 'https://infra.example.com/health',
|
|
83
|
+
healthTimeout: 3000,
|
|
84
|
+
unavailableMessage: 'Connect to the company network or VPN.',
|
|
82
85
|
},
|
|
83
86
|
},
|
|
84
87
|
},
|
|
@@ -113,6 +116,38 @@ For example, the first request made by this table automatically requests a
|
|
|
113
116
|
|
|
114
117
|
```vue
|
|
115
118
|
<L-Table client="business" model-name="Order" />
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
### Client availability and menus
|
|
122
|
+
|
|
123
|
+
When a client has a `healthURL`, nuxt-light checks it on startup, when the
|
|
124
|
+
browser comes back online, and when the tab becomes visible. Menu items can
|
|
125
|
+
declare that they require that client:
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
- label: Servers
|
|
130
|
+
icon: sym_o_dns
|
|
131
|
+
to: /Server
|
|
132
|
+
permission: server.list
|
|
133
|
+
requiresClient: infra
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
`L-Menu` keeps the authorized item visible, but disables it and shows the
|
|
138
|
+
configured message while the client is checking or unavailable. Applications
|
|
139
|
+
can use the same state for route guards or page notices:
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const availability = useLightClientAvailability()
|
|
144
|
+
|
|
145
|
+
if (!availability.isAvailable('infra')) {
|
|
146
|
+
// Show a company-network/VPN notice.
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Clients without a `healthURL` remain available and keep the previous behavior.
|
|
116
151
|
```
|
|
117
152
|
|
|
118
153
|
### Backwards-compatible single endpoint
|
package/dist/module.json
CHANGED
|
@@ -2,8 +2,18 @@
|
|
|
2
2
|
import { useRoute } from "vue-router";
|
|
3
3
|
import { ref, computed } from "vue";
|
|
4
4
|
import { useLight } from "#imports";
|
|
5
|
+
import useLightClientAvailability from "../../composables/useLightClientAvailability";
|
|
5
6
|
const props = defineProps(["value", "dense", "expandAll"]);
|
|
6
7
|
const light = useLight();
|
|
8
|
+
const availability = useLightClientAvailability();
|
|
9
|
+
const requiredClient = (menu) => typeof menu?.requiresClient === "string" ? menu.requiresClient : void 0;
|
|
10
|
+
const isMenuAvailable = (menu) => availability.isAvailable(requiredClient(menu));
|
|
11
|
+
const availabilityMessage = (menu) => {
|
|
12
|
+
const client = requiredClient(menu);
|
|
13
|
+
if (!client) return "";
|
|
14
|
+
if (availability.isChecking(client)) return "Checking service availability\u2026";
|
|
15
|
+
return availability.message(client);
|
|
16
|
+
};
|
|
7
17
|
const isShowExpansionItem = (menu) => {
|
|
8
18
|
if (menu.children && menu.children.length > 0) {
|
|
9
19
|
return true;
|
|
@@ -48,6 +58,7 @@ const hasChildLink = () => {
|
|
|
48
58
|
<template v-for="menu in value">
|
|
49
59
|
|
|
50
60
|
<q-expansion-item :default-opened="expandAll || hasLink(menu)" :label="$t(menu.label)" :icon="menu.icon" :dense="dense"
|
|
61
|
+
:disable="!isMenuAvailable(menu)" :caption="!isMenuAvailable(menu) ? availabilityMessage(menu) : undefined"
|
|
51
62
|
v-if="isShowExpansionItem(menu)" :group="expandAll ? undefined : group">
|
|
52
63
|
<l-menu class="q-pl-md" :value="menu.children" :dense="dense" :expand-all="expandAll"></l-menu>
|
|
53
64
|
</q-expansion-item>
|
|
@@ -56,20 +67,34 @@ const hasChildLink = () => {
|
|
|
56
67
|
<q-separator v-if="menu.type == 'separator'" :spaced="menu.spaced" />
|
|
57
68
|
<q-item-label header v-if="menu.type == 'header'">{{ menu.label }}</q-item-label>
|
|
58
69
|
<template v-if="menu.to && (menu.to.startsWith('http://') || menu.to.startsWith('https://'))">
|
|
59
|
-
<q-item v-ripple :href="menu.to" v-if="!value.type" target="_blank"
|
|
70
|
+
<q-item v-ripple :href="isMenuAvailable(menu) ? menu.to : undefined" v-if="!value.type" target="_blank"
|
|
71
|
+
:disable="!isMenuAvailable(menu)">
|
|
60
72
|
<q-item-section avatar>
|
|
61
73
|
<q-icon :name="menu.icon" />
|
|
62
74
|
</q-item-section>
|
|
63
|
-
<q-item-section>
|
|
75
|
+
<q-item-section>
|
|
76
|
+
{{ $t(menu.label) }}
|
|
77
|
+
<q-item-label v-if="!isMenuAvailable(menu)" caption>{{ availabilityMessage(menu) }}</q-item-label>
|
|
78
|
+
</q-item-section>
|
|
79
|
+
<q-item-section v-if="!isMenuAvailable(menu)" side>
|
|
80
|
+
<q-icon name="sym_o_vpn_lock"><q-tooltip>{{ availabilityMessage(menu) }}</q-tooltip></q-icon>
|
|
81
|
+
</q-item-section>
|
|
64
82
|
</q-item>
|
|
65
83
|
</template>
|
|
66
84
|
|
|
67
85
|
<template v-else>
|
|
68
|
-
<q-item v-ripple :to="menu.to" v-if="!value.type"
|
|
86
|
+
<q-item v-ripple :to="isMenuAvailable(menu) ? menu.to : undefined" v-if="!value.type"
|
|
87
|
+
:disable="!isMenuAvailable(menu)">
|
|
69
88
|
<q-item-section avatar>
|
|
70
89
|
<q-icon :name="menu.icon" />
|
|
71
90
|
</q-item-section>
|
|
72
|
-
<q-item-section>
|
|
91
|
+
<q-item-section>
|
|
92
|
+
{{ $t(menu.label) }}
|
|
93
|
+
<q-item-label v-if="!isMenuAvailable(menu)" caption>{{ availabilityMessage(menu) }}</q-item-label>
|
|
94
|
+
</q-item-section>
|
|
95
|
+
<q-item-section v-if="!isMenuAvailable(menu)" side>
|
|
96
|
+
<q-icon name="sym_o_vpn_lock"><q-tooltip>{{ availabilityMessage(menu) }}</q-tooltip></q-icon>
|
|
97
|
+
</q-item-section>
|
|
73
98
|
</q-item>
|
|
74
99
|
</template>
|
|
75
100
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type LightClientAvailabilityStatus = 'checking' | 'available' | 'unavailable';
|
|
2
|
+
export interface LightClientAvailabilityOptions {
|
|
3
|
+
healthURL?: string;
|
|
4
|
+
healthTimeout?: number;
|
|
5
|
+
unavailableMessage?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface LightClientAvailabilityState {
|
|
8
|
+
status: LightClientAvailabilityStatus;
|
|
9
|
+
message: string;
|
|
10
|
+
checkedAt: number | null;
|
|
11
|
+
}
|
|
12
|
+
export declare function registerLightClientAvailability(name: string, config?: LightClientAvailabilityOptions): void;
|
|
13
|
+
export declare function clearLightClientAvailability(): void;
|
|
14
|
+
export declare function checkLightClientAvailability(name: string): Promise<LightClientAvailabilityStatus>;
|
|
15
|
+
export declare function checkAllLightClientAvailability(): Promise<void>;
|
|
16
|
+
export default function useLightClientAvailability(): {
|
|
17
|
+
states: {
|
|
18
|
+
readonly [x: string]: {
|
|
19
|
+
readonly status: LightClientAvailabilityStatus;
|
|
20
|
+
readonly message: string;
|
|
21
|
+
readonly checkedAt: number | null;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
status: (name?: string) => LightClientAvailabilityStatus;
|
|
25
|
+
isAvailable: (name?: string) => boolean;
|
|
26
|
+
isChecking: (name?: string) => boolean;
|
|
27
|
+
message: (name?: string) => string;
|
|
28
|
+
check: typeof checkLightClientAvailability;
|
|
29
|
+
checkAll: typeof checkAllLightClientAvailability;
|
|
30
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { reactive, readonly } from "vue";
|
|
2
|
+
const DEFAULT_TIMEOUT = 3e3;
|
|
3
|
+
const DEFAULT_MESSAGE = "This service is currently unavailable.";
|
|
4
|
+
const options = /* @__PURE__ */ new Map();
|
|
5
|
+
const checks = /* @__PURE__ */ new Map();
|
|
6
|
+
const states = reactive({});
|
|
7
|
+
export function registerLightClientAvailability(name, config = {}) {
|
|
8
|
+
options.set(name, config);
|
|
9
|
+
states[name] = {
|
|
10
|
+
status: config.healthURL ? "checking" : "available",
|
|
11
|
+
message: "",
|
|
12
|
+
checkedAt: null
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function clearLightClientAvailability() {
|
|
16
|
+
options.clear();
|
|
17
|
+
checks.clear();
|
|
18
|
+
Object.keys(states).forEach((name) => delete states[name]);
|
|
19
|
+
}
|
|
20
|
+
export async function checkLightClientAvailability(name) {
|
|
21
|
+
const config = options.get(name);
|
|
22
|
+
if (!config) {
|
|
23
|
+
states[name] = {
|
|
24
|
+
status: "unavailable",
|
|
25
|
+
message: `Light client "${name}" is not configured.`,
|
|
26
|
+
checkedAt: Date.now()
|
|
27
|
+
};
|
|
28
|
+
return "unavailable";
|
|
29
|
+
}
|
|
30
|
+
if (!config.healthURL) {
|
|
31
|
+
states[name] = { status: "available", message: "", checkedAt: Date.now() };
|
|
32
|
+
return "available";
|
|
33
|
+
}
|
|
34
|
+
const checkId = (checks.get(name) ?? 0) + 1;
|
|
35
|
+
checks.set(name, checkId);
|
|
36
|
+
states[name] = { ...states[name], status: "checking", message: "" };
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeout = globalThis.setTimeout(
|
|
39
|
+
() => controller.abort(),
|
|
40
|
+
Math.max(1, config.healthTimeout ?? DEFAULT_TIMEOUT)
|
|
41
|
+
);
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(config.healthURL, {
|
|
44
|
+
method: "GET",
|
|
45
|
+
cache: "no-store",
|
|
46
|
+
credentials: "omit",
|
|
47
|
+
signal: controller.signal
|
|
48
|
+
});
|
|
49
|
+
const status = response.ok ? "available" : "unavailable";
|
|
50
|
+
if (checks.get(name) === checkId) {
|
|
51
|
+
states[name] = {
|
|
52
|
+
status,
|
|
53
|
+
message: response.ok ? "" : config.unavailableMessage ?? DEFAULT_MESSAGE,
|
|
54
|
+
checkedAt: Date.now()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return status;
|
|
58
|
+
} catch {
|
|
59
|
+
if (checks.get(name) === checkId) {
|
|
60
|
+
states[name] = {
|
|
61
|
+
status: "unavailable",
|
|
62
|
+
message: config.unavailableMessage ?? DEFAULT_MESSAGE,
|
|
63
|
+
checkedAt: Date.now()
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return "unavailable";
|
|
67
|
+
} finally {
|
|
68
|
+
globalThis.clearTimeout(timeout);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function checkAllLightClientAvailability() {
|
|
72
|
+
await Promise.all([...options.keys()].map((name) => checkLightClientAvailability(name)));
|
|
73
|
+
}
|
|
74
|
+
export default function useLightClientAvailability() {
|
|
75
|
+
const status = (name) => {
|
|
76
|
+
if (!name) return "available";
|
|
77
|
+
return states[name]?.status ?? "unavailable";
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
states: readonly(states),
|
|
81
|
+
status,
|
|
82
|
+
isAvailable: (name) => status(name) === "available",
|
|
83
|
+
isChecking: (name) => status(name) === "checking",
|
|
84
|
+
message: (name) => name ? states[name]?.message ?? `Light client "${name}" is not configured.` : "",
|
|
85
|
+
check: checkLightClientAvailability,
|
|
86
|
+
checkAll: checkAllLightClientAvailability
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/runtime/plugin.js
CHANGED
|
@@ -13,19 +13,28 @@ import { plugin, defaultConfig } from "@formkit/vue";
|
|
|
13
13
|
import getApiBase from "./composables/getApiBase.js";
|
|
14
14
|
import useLight from "./composables/useLight.js";
|
|
15
15
|
import useLightClient, { clearLightClients, registerLightClient } from "./composables/useLightClient.js";
|
|
16
|
+
import {
|
|
17
|
+
checkAllLightClientAvailability,
|
|
18
|
+
checkLightClientAvailability,
|
|
19
|
+
clearLightClientAvailability,
|
|
20
|
+
registerLightClientAvailability
|
|
21
|
+
} from "./composables/useLightClientAvailability.js";
|
|
16
22
|
import { zhTW } from "@formkit/i18n";
|
|
17
23
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
18
24
|
const runtimeConfig = useRuntimeConfig();
|
|
19
25
|
const lightConfig = runtimeConfig.public.light;
|
|
20
26
|
const configuredClients = lightConfig?.clients ?? {};
|
|
21
27
|
clearLightClients();
|
|
28
|
+
clearLightClientAvailability();
|
|
22
29
|
for (const [name, config] of Object.entries(configuredClients)) {
|
|
23
30
|
const baseURL = typeof config === "string" ? config : config.baseURL;
|
|
24
31
|
if (!baseURL) throw new Error(`Light client "${name}" requires a baseURL`);
|
|
25
32
|
registerLightClient(name, createClient(baseURL));
|
|
33
|
+
registerLightClientAvailability(name, typeof config === "string" ? {} : config);
|
|
26
34
|
}
|
|
27
35
|
if (!configuredClients.auth) {
|
|
28
36
|
registerLightClient("auth", createClient(getApiBase()));
|
|
37
|
+
registerLightClientAvailability("auth");
|
|
29
38
|
}
|
|
30
39
|
const authClient = useLightClient();
|
|
31
40
|
setApiClient(authClient);
|
|
@@ -33,6 +42,21 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
33
42
|
if (name === "auth" || typeof config === "string" || !config.audience) continue;
|
|
34
43
|
useLightClient(name).useAudience(authClient, config.audience);
|
|
35
44
|
}
|
|
45
|
+
if (import.meta.client) {
|
|
46
|
+
for (const name of Object.keys(configuredClients)) {
|
|
47
|
+
void checkLightClientAvailability(name);
|
|
48
|
+
}
|
|
49
|
+
const recheckClients = () => void checkAllLightClientAvailability();
|
|
50
|
+
const recheckVisibleClients = () => {
|
|
51
|
+
if (document.visibilityState === "visible") recheckClients();
|
|
52
|
+
};
|
|
53
|
+
window.addEventListener("online", recheckClients);
|
|
54
|
+
document.addEventListener("visibilitychange", recheckVisibleClients);
|
|
55
|
+
nuxtApp.hook("app:unmounted", () => {
|
|
56
|
+
window.removeEventListener("online", recheckClients);
|
|
57
|
+
document.removeEventListener("visibilitychange", recheckVisibleClients);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
36
60
|
defineModel("Permission", {}).setDataPath("app.listPermission");
|
|
37
61
|
defineModel("SystemValue", {}).setDataPath("app.listSystemValue");
|
|
38
62
|
defineModel("Config", {}).setDataPath("app.listConfig");
|