Replace vue-toast-notification with custom useAppToast across components and enhance Edge Gateway installer state management.

This commit is contained in:
Jeppe Bundgaard
2026-04-23 11:07:12 +02:00
parent 50190da52f
commit 4ef59b0355
9 changed files with 43134 additions and 51 deletions
+42949
View File
File diff suppressed because it is too large Load Diff
@@ -3,15 +3,15 @@ import {computed, onMounted, ref, watch} from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {BSwitch} from "buefy"; import {BSwitch} from "buefy";
import {useToast} from 'vue-toast-notification';
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import { useAppToast } from "@/composables/useAppToast.js";
import { import {
getDepartmentSelfServeEnabled, getDepartmentSelfServeEnabled,
setDepartmentSelfServeEnabled, setDepartmentSelfServeEnabled,
} from "@/composables/departmentSelfServeEnabled.js"; } from "@/composables/departmentSelfServeEnabled.js";
const router = useRouter(); const router = useRouter();
const toast = useToast(); const toast = useAppToast();
const { t } = useI18n(); const { t } = useI18n();
const selfServeEnabled = ref(false); const selfServeEnabled = ref(false);
@@ -3,6 +3,7 @@ import { defineProps, computed, watch } from 'vue';
import type { NavigationItemProps } from '@/components/models/navigation/NavigationItem.vue'; import type { NavigationItemProps } from '@/components/models/navigation/NavigationItem.vue';
import { toggleExpanded } from '../ViewportHeaderSettings.vue'; import { toggleExpanded } from '../ViewportHeaderSettings.vue';
import { SessionUser } from '@/components/session/token/SessionUser.vue'; import { SessionUser } from '@/components/session/token/SessionUser.vue';
import { useAppToast } from "@/composables/useAppToast.js";
const props = defineProps({ const props = defineProps({
item: { item: {
type: Object as () => NavigationItemProps, type: Object as () => NavigationItemProps,
@@ -20,11 +21,10 @@ const props = defineProps({
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const router = useRouter(); const router = useRouter();
import { useToast } from 'vue-toast-notification';
import 'vue-toast-notification/dist/theme-sugar.css'; import 'vue-toast-notification/dist/theme-sugar.css';
// In your setup: // In your setup:
const toast = useToast(); const toast = useAppToast();
const redirect = async (to: string) => { const redirect = async (to: string) => {
if (!to) { if (!to) {
@@ -256,4 +256,4 @@ updateDefaultDepartmentId();
border-bottom: none; border-bottom: none;
} }
</style> </style>
+141 -31
View File
@@ -6,6 +6,7 @@ import {
createEdgeGatewayOperation, createEdgeGatewayOperation,
deleteEdgeGateway, deleteEdgeGateway,
getEdgeGateway, getEdgeGateway,
getEdgeGatewayInstallTokenStatus,
listEdgeGatewayDepartments, listEdgeGatewayDepartments,
listEdgeGateways, listEdgeGateways,
peekCachedEdgeGateway, peekCachedEdgeGateway,
@@ -79,6 +80,16 @@ const installerLabel = ref("");
const installerCommand = ref(""); const installerCommand = ref("");
const installerCopyState = ref("idle"); const installerCopyState = ref("idle");
const installerClaimPollToken = ref(0); const installerClaimPollToken = ref(0);
const createInstallerStatus = () => ({
visible: false,
claimTokenId: null,
state: "Idle",
step: "Waiting to start",
error: "",
diagnostics: [],
updatedAt: null,
});
const installerStatus = ref(createInstallerStatus());
const flashMessage = ref(""); const flashMessage = ref("");
const errorState = ref(null); const errorState = ref(null);
const unavailableGatewayId = ref(null); const unavailableGatewayId = ref(null);
@@ -147,6 +158,29 @@ const tone = (status) => ({ ONLINE: "success", DEGRADED: "warning", OFFLINE: "da
const statusLabel = (status) => ({ ONLINE: "Online", DEGRADED: "Needs attention", OFFLINE: "Offline" }[status] || "Unknown"); const statusLabel = (status) => ({ ONLINE: "Online", DEGRADED: "Needs attention", OFFLINE: "Offline" }[status] || "Unknown");
const discoveryLabel = (status) => ({ READY: "Ready", STALE: "Stale", PENDING: "Pending", FAILED: "Failed" }[status] || "Unknown"); const discoveryLabel = (status) => ({ READY: "Ready", STALE: "Stale", PENDING: "Pending", FAILED: "Failed" }[status] || "Unknown");
const transportLabel = (mode) => ({ gateway: "Gateway", cloud: "Cloud" }[mode] || mode || "Unknown"); const transportLabel = (mode) => ({ gateway: "Gateway", cloud: "Cloud" }[mode] || mode || "Unknown");
const installerStateLabel = (status) =>
({
PENDING: "Pending",
RUNNING: "Running",
CLAIMED: "Connected",
FAILED: "Failed",
}[String(status || "").toUpperCase()] || "Unknown");
const installerStepLabel = (step) =>
({
PENDING: "Waiting to start",
VERIFY_TOKEN: "Verifying token",
INSTALL_PACKAGES: "Installing packages",
DOWNLOAD_ARTIFACTS: "Downloading artifacts",
WRITE_CONFIG: "Writing config",
START_STACK: "Starting stack",
WAIT_FOR_CLAIM: "Waiting for gateway claim",
CLAIMED: "Gateway claimed",
FAILED: "Installer failed",
}[String(step || "").toUpperCase()] ||
String(step || "Waiting to start")
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (letter) => letter.toUpperCase()));
const deptName = (id) => departments.value.find((item) => Number(item.id) === Number(id))?.name || `Department ${id}`; const deptName = (id) => departments.value.find((item) => Number(item.id) === Number(id))?.name || `Department ${id}`;
const metricText = (value, suffix = "") => (Number.isFinite(Number(value)) ? `${Number(value)}${suffix}` : "No data"); const metricText = (value, suffix = "") => (Number.isFinite(Number(value)) ? `${Number(value)}${suffix}` : "No data");
const normalizeFleetUsage = (value) => { const normalizeFleetUsage = (value) => {
@@ -406,6 +440,23 @@ const scheduleInstallerCopyReset = () => {
}, 1800); }, 1800);
}; };
const syncInstallerStatus = (session, overrides = {}) => {
installerStatus.value = {
...createInstallerStatus(),
...installerStatus.value,
visible: true,
claimTokenId: session?.claim_token_id ?? installerStatus.value.claimTokenId ?? null,
state: installerStateLabel(session?.status),
step: installerStepLabel(session?.step),
error:
session?.last_error ||
(String(session?.status || "").toUpperCase() === "FAILED" ? session?.message || "Installer failed." : ""),
diagnostics: Array.isArray(session?.diagnostics) ? session.diagnostics : [],
updatedAt: session?.updated_at || null,
...overrides,
};
};
const copyPlainText = async (value) => { const copyPlainText = async (value) => {
const text = String(value || ""); const text = String(value || "");
if (!text.trim()) { if (!text.trim()) {
@@ -747,46 +798,46 @@ const load = async () => {
} }
}; };
const pollForClaimedGateway = async ({ existingIds, baseline, departmentId, label, pollToken }) => { const pollForClaimedGateway = async ({ claimTokenId, pollToken }) => {
const normalizedDepartmentId = Number(departmentId || 0);
const normalizedLabel = normLabel(label);
for (let attempt = 0; attempt < 60; attempt += 1) { for (let attempt = 0; attempt < 60; attempt += 1) {
if (installerClaimPollToken.value !== pollToken) { if (installerClaimPollToken.value !== pollToken) {
return; return;
} }
await sleep(attempt === 0 ? 1500 : 2000); await sleep(attempt === 0 ? 1500 : 2000);
await refreshFleet(); const sessionResponse = await getEdgeGatewayInstallTokenStatus(claimTokenId);
const session = unwrap(sessionResponse, null);
if (!session) {
continue;
}
const matchingGateways = gateways.value.filter((item) => { syncInstallerStatus(session);
if (Number(item.department_id) !== normalizedDepartmentId) return false;
if (normalizedLabel && normLabel(item.label) !== normalizedLabel) return false;
return true;
});
const claimed = if (String(session.status || "").toUpperCase() === "FAILED") {
matchingGateways.find((item) => !existingIds.has(Number(item.id))) || fail(new Error(session.last_error || session.message || "Installer failed."));
(() => { return;
const reusedGateways = sortByMostRecentHeartbeat( }
matchingGateways.filter((item) => gatewayClaimStateChanged(item, baseline.get(String(item.id))))
);
if (reusedGateways.length === 1) {
return reusedGateways[0];
}
if (normalizedLabel && reusedGateways.length > 1) {
return reusedGateways[0];
}
return null;
})();
if (claimed) { if (String(session.status || "").toUpperCase() === "CLAIMED" && session.gateway_id) {
flashMessage.value = existingIds.has(Number(claimed.id)) ? "Gateway reconnected." : "Gateway connected."; flashMessage.value = session.message || "Gateway connected.";
clearError(); clearError();
await selectGateway(claimed.id, "overview"); await refreshFleet({ forceRefresh: true });
await selectGateway(session.gateway_id, "overview");
return; return;
} }
} }
syncInstallerStatus(
{
claim_token_id: claimTokenId,
status: "FAILED",
step: "WAIT_FOR_CLAIM",
last_error: "The gateway did not claim the installer token in time.",
},
{
error: "The gateway did not claim the installer token in time.",
}
);
}; };
const generateInstaller = async () => { const generateInstaller = async () => {
@@ -798,18 +849,44 @@ const generateInstaller = async () => {
loading.value.installer = true; loading.value.installer = true;
installerCopyState.value = "idle"; installerCopyState.value = "idle";
syncInstallerStatus(
{
claim_token_id: null,
status: "RUNNING",
step: "PENDING",
last_error: null,
diagnostics: [],
},
{
step: "Waiting to start",
error: "",
diagnostics: [],
}
);
try { try {
const label = installerLabel.value.trim(); const label = installerLabel.value.trim();
const pollToken = installerClaimPollToken.value + 1; const pollToken = installerClaimPollToken.value + 1;
const existingIds = new Set(gateways.value.map((item) => Number(item.id)));
const baseline = buildGatewayClaimBaseline(gateways.value);
const response = await createEdgeGatewayInstallToken({ department_id: departmentId, label: label || undefined }); const response = await createEdgeGatewayInstallToken({ department_id: departmentId, label: label || undefined });
const installSession = unwrap(response, null) || {};
installerClaimPollToken.value = pollToken; installerClaimPollToken.value = pollToken;
installerCommand.value = unwrap(response, null)?.install_command || ""; installerCommand.value = installSession.install_command || "";
syncInstallerStatus(
{
claim_token_id: installSession.claim_token_id || null,
status: "RUNNING",
step: "PENDING",
last_error: null,
diagnostics: [],
},
{
claimTokenId: installSession.claim_token_id || null,
}
);
flashMessage.value = "Installer token generated."; flashMessage.value = "Installer token generated.";
clearError(); clearError();
pollForClaimedGateway({ existingIds, baseline, departmentId, label, pollToken }).catch(() => {}); pollForClaimedGateway({ claimTokenId: installSession.claim_token_id, pollToken }).catch((error) => fail(error));
} catch (error) { } catch (error) {
installerStatus.value = createInstallerStatus();
fail(error); fail(error);
} finally { } finally {
loading.value.installer = false; loading.value.installer = false;
@@ -1218,6 +1295,30 @@ onUnmounted(() => {
</article> </article>
</div> </div>
<div v-if="!filteredGatewayViews.length" class="edge-state-card" data-testid="gateway-empty-state">
No gateways are currently available for this fleet view.
</div>
<article v-if="installerStatus.visible" class="edge-stat-card" data-testid="gateway-installer-status">
<p class="edge-card-label">Installer status</p>
<strong data-testid="gateway-installer-status-state">{{ installerStatus.state }}</strong>
<p data-testid="gateway-installer-status-step">{{ installerStatus.step }}</p>
<p v-if="installerStatus.error" data-testid="gateway-installer-status-error">{{ installerStatus.error }}</p>
<details
v-if="installerStatus.diagnostics.length"
class="edge-installer-diagnostics"
data-testid="gateway-installer-status-diagnostics"
>
<summary>Diagnostics</summary>
<ul class="edge-operation-list">
<li v-for="diagnostic in installerStatus.diagnostics" :key="`${diagnostic.name}-${diagnostic.output}`">
<strong>{{ diagnostic.name }}</strong>
<pre class="edge-json-block">{{ diagnostic.output }}</pre>
</li>
</ul>
</details>
</article>
<div v-if="allowDestructive" class="edge-onboarding-card" data-testid="gateway-onboarding"> <div v-if="allowDestructive" class="edge-onboarding-card" data-testid="gateway-onboarding">
<div class="edge-panel-head"> <div class="edge-panel-head">
<h3 class="title is-6">Install new gateway</h3> <h3 class="title is-6">Install new gateway</h3>
@@ -1272,6 +1373,15 @@ onUnmounted(() => {
</p> </p>
</div> </div>
<div class="edge-action-group"> <div class="edge-action-group">
<button
v-if="props.departmentId && currentView !== 'overview'"
type="button"
class="button is-light"
data-testid="gateway-open-full-page"
@click="emit('open-gateway-page', selectedGatewayView.id)"
>
Open full page
</button>
<span class="tag is-light">{{ selectedGatewayView.discoveryStatusLabel }}</span> <span class="tag is-light">{{ selectedGatewayView.discoveryStatusLabel }}</span>
<span class="tag is-light">{{ selectedGatewayView.departmentTransportModeLabel }}</span> <span class="tag is-light">{{ selectedGatewayView.departmentTransportModeLabel }}</span>
</div> </div>
@@ -7,7 +7,7 @@ import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFall
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue"; import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
import { Goals } from "@/components/session/token/SessionUser/Objects/Goals.vue"; import { Goals } from "@/components/session/token/SessionUser/Objects/Goals.vue";
import { showGoalCreateModal, showGoalEditModal } from "./functions/showGoalFormModal"; import { showGoalCreateModal, showGoalEditModal } from "./functions/showGoalFormModal";
import { useToast } from 'vue-toast-notification'; import { useAppToast } from "@/composables/useAppToast.js";
import { getDepartmentName } from "@/components/pagination/departmentTabs.vue"; import { getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import {BToast, BTooltip} from "buefy"; import {BToast, BTooltip} from "buefy";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
@@ -26,7 +26,7 @@ const departmentId = ref(SessionUser.functions.getDepartmentIdFromUrl());
const goals = ref([]); const goals = ref([]);
const loading = ref(true); const loading = ref(true);
const activeTab = ref('active'); const activeTab = ref('active');
const toast = useToast(); const toast = useAppToast();
const { t } = useI18n(); const { t } = useI18n();
const timeframe = ref('all'); const timeframe = ref('all');
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useToast } from "vue-toast-notification";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue"; import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue"; import DepartmentDashboardPageWrapper from "@/views/dashboards/departmentDashboard/DepartmentDashboardPageWrapper.vue";
import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue"; import NotFoundFallBackPageWrapper from "@/components/page/wrappers/NotFoundFallBackPageWrapper.vue";
@@ -10,10 +9,11 @@ import SelfServeTryModal from "@/components/displays/department/tables/SelfServe
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue"; import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { useAppToast } from "@/composables/useAppToast.js";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const toast = useToast(); const toast = useAppToast();
const parseIntOrZero = (value) => { const parseIntOrZero = (value) => {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch, defineProps, defineEmits, defineExpose } from 'vue'; import { ref, computed, onMounted, watch, defineProps, defineEmits, defineExpose } from 'vue';
import {useToast} from 'vue-toast-notification';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAppToast } from "@/composables/useAppToast.js";
const { t } = useI18n(); const { t } = useI18n();
//import 'vue-toast-notification/dist/theme-sugar.css'; //import 'vue-toast-notification/dist/theme-sugar.css';
@@ -106,7 +106,7 @@ const toastDuration = ref(0);
let instance = null; let instance = null;
const $toast = useToast(); const $toast = useAppToast();
const createToast = () => { const createToast = () => {
if (!(props.queued > 0 || props.succeeded > 0 || props.failed > 0 )) { if (!(props.queued > 0 || props.succeeded > 0 || props.failed > 0 )) {
return; // Do not create a toast if maxProgress is not greater than 0 return; // Do not create a toast if maxProgress is not greater than 0
@@ -223,4 +223,4 @@ onMounted(() => {
</template> </template>
<style scoped> <style scoped>
</style> </style>
@@ -7,10 +7,10 @@ import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/
import PageTitle from "@/components/global/PageTitle.vue"; import PageTitle from "@/components/global/PageTitle.vue";
import ModulesPagination from "@/components/displays/pagination/models/SuperUser/ModulesPagination.vue"; import ModulesPagination from "@/components/displays/pagination/models/SuperUser/ModulesPagination.vue";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useToast } from 'vue-toast-notification'; import { useAppToast } from "@/composables/useAppToast.js";
const { t } = useI18n(); const { t } = useI18n();
const toast = useToast(); const toast = useAppToast();
const router = useRouter(); const router = useRouter();
const laneId = router.currentRoute.value.params.id; const laneId = router.currentRoute.value.params.id;
const lane = ref(null); const lane = ref(null);
+30 -6
View File
@@ -1,9 +1,10 @@
import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
import { viteSingleFile } from 'vite-plugin-singlefile' import { viteSingleFile } from 'vite-plugin-singlefile'
import { VitePWA } from 'vite-plugin-pwa'
import VueJsx from '@vitejs/plugin-vue-jsx' import VueJsx from '@vitejs/plugin-vue-jsx'
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
@@ -60,10 +61,28 @@ function patchBuefyCssMediaQuery() {
} }
} }
export default defineConfig(({ mode }) => { async function loadVitePwaPlugin(workspaceRoot) {
const localPluginEntry = path.resolve(workspaceRoot, 'node_modules.external-link', 'vite-plugin-pwa', 'dist', 'index.js')
try {
if (fs.existsSync(localPluginEntry)) {
return await import(pathToFileURL(localPluginEntry).href)
}
} catch (error) {
console.warn('Failed to load vite-plugin-pwa from node_modules.external-link:', error.message)
}
console.warn('vite-plugin-pwa is unavailable; continuing without PWA support in this environment.')
return null
}
export default defineConfig(async ({ mode }) => {
const isProd = mode === 'production' const isProd = mode === 'production'
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1' const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
const shouldDisableDepOptimizer = process.platform === 'win32'
const workspaceRoot = process.cwd() const workspaceRoot = process.cwd()
const vitePwaModule = await loadVitePwaPlugin(workspaceRoot)
const VitePWA = vitePwaModule?.VitePWA
// Set COMMIT_HASH env var for use in the app // Set COMMIT_HASH env var for use in the app
const version = process.env.npm_package_version || '0.0.0' const version = process.env.npm_package_version || '0.0.0'
@@ -106,7 +125,7 @@ export default defineConfig(({ mode }) => {
VueJsx(), VueJsx(),
!isProd && !isPlaywrightRuntime && vueDevTools(), !isProd && !isPlaywrightRuntime && vueDevTools(),
enableSingleFile && viteSingleFile(), enableSingleFile && viteSingleFile(),
VitePWA({ VitePWA && VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
injectRegister: isProd ? 'auto' : false, injectRegister: isProd ? 'auto' : false,
strategies: 'generateSW', strategies: 'generateSW',
@@ -222,11 +241,16 @@ export default defineConfig(({ mode }) => {
ignored: ['**/output/playwright/**'] ignored: ['**/output/playwright/**']
} }
}, },
optimizeDeps: isPlaywrightRuntime optimizeDeps: shouldDisableDepOptimizer
? { ? {
entries: ['index.html'] noDiscovery: true,
include: []
} }
: undefined : isPlaywrightRuntime
? {
entries: ['index.html']
}
: undefined
} }
}) })