Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a6fe302d | ||
|
|
eb2d119823 | ||
|
|
f7e845b3d2 | ||
|
|
88c65776ad | ||
|
|
17937200f0 | ||
|
|
b26e50cd1e |
@@ -99,6 +99,49 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
e2e-edge-gateway:
|
||||||
|
if: github.event_name != 'schedule'
|
||||||
|
needs: build-and-unit
|
||||||
|
runs-on: [self-hosted, Linux, X64, default]
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
project: [chromium-desktop, chromium-mobile]
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run Edge Gateway route and visual tests (${{ matrix.project }})
|
||||||
|
run: >-
|
||||||
|
npx playwright test
|
||||||
|
tests/e2e/edge-gateways.routes.spec.js
|
||||||
|
tests/e2e/edge-gateways.visual.spec.js
|
||||||
|
--project="${{ matrix.project }}"
|
||||||
|
--workers=1
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report-edge-gateway-${{ matrix.project }}
|
||||||
|
path: |
|
||||||
|
output/playwright/report
|
||||||
|
output/playwright/test-results
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
e2e-full:
|
e2e-full:
|
||||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
||||||
needs: build-and-unit
|
needs: build-and-unit
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from 'vue';
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { loadList } from "@/components/pagination/paginatedList.vue";
|
import { loadList } from "@/components/pagination/paginatedList.vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
import PageTitle from "@/components/global/PageTitle.vue";
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const getScanners = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createScanner = (department_id, name, notes) => {
|
export const createScanner = (department_id, name, notes, lane_id = undefined) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
@@ -23,7 +23,8 @@ export const createScanner = (department_id, name, notes) => {
|
|||||||
return axios.post(API_URL + '/numberplatescanners', {
|
return axios.post(API_URL + '/numberplatescanners', {
|
||||||
department_id,
|
department_id,
|
||||||
name,
|
name,
|
||||||
notes
|
notes,
|
||||||
|
...(lane_id !== undefined ? { lane_id } : {})
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
@@ -31,7 +32,7 @@ export const createScanner = (department_id, name, notes) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editScanner = (id, department_id, name, notes) => {
|
export const editScanner = (id, department_id, name, notes, lane_id = undefined) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
@@ -40,7 +41,8 @@ export const editScanner = (id, department_id, name, notes) => {
|
|||||||
id,
|
id,
|
||||||
department_id,
|
department_id,
|
||||||
name,
|
name,
|
||||||
notes
|
notes,
|
||||||
|
...(lane_id !== undefined ? { lane_id } : {})
|
||||||
}, {
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
|
|||||||
@@ -27,7 +27,27 @@ const validateDepartmentGateConfig = (config) => {
|
|||||||
const normalizedType = String(normalizedConfig?.type ?? '').trim().toUpperCase();
|
const normalizedType = String(normalizedConfig?.type ?? '').trim().toUpperCase();
|
||||||
|
|
||||||
if (normalizedType !== 'PHONE_CALL') {
|
if (normalizedType !== 'PHONE_CALL') {
|
||||||
return normalizedConfig;
|
if (normalizedType !== 'RELAY') {
|
||||||
|
return normalizedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayId = String(normalizedConfig?.relay_id ?? '').trim();
|
||||||
|
if (relayId === '') {
|
||||||
|
throw new Error("RELAY gates require a 'relay_id' value.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedConfig?.pulse_seconds !== undefined && normalizedConfig?.pulse_seconds !== null && normalizedConfig?.pulse_seconds !== '') {
|
||||||
|
const pulseSeconds = Number(normalizedConfig.pulse_seconds);
|
||||||
|
if (!Number.isFinite(pulseSeconds) || pulseSeconds <= 0) {
|
||||||
|
throw new Error("RELAY gates require a positive 'pulse_seconds' value when provided.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalizedConfig,
|
||||||
|
type: 'RELAY',
|
||||||
|
relay_id: relayId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const phoneNumber = String(normalizedConfig?.phone_number ?? '').trim();
|
const phoneNumber = String(normalizedConfig?.phone_number ?? '').trim();
|
||||||
|
|||||||
@@ -0,0 +1,789 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||||
|
import {
|
||||||
|
getEdgeGatewayDepartmentWorkspace,
|
||||||
|
rotateNumberPlateScannerKey,
|
||||||
|
updateNumberPlateScanner,
|
||||||
|
unwrapEdgeGatewayResponse,
|
||||||
|
} from "@/services/edgeGateways.js";
|
||||||
|
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
departmentId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
notice: null,
|
||||||
|
workspace: null,
|
||||||
|
scannerLaneDrafts: {},
|
||||||
|
scannerBusy: {},
|
||||||
|
rotatedKeys: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "overview", label: "Overview" },
|
||||||
|
{ id: "lanes", label: "Lanes & Self-Serve" },
|
||||||
|
{ id: "gates", label: "Gates" },
|
||||||
|
{ id: "scanners", label: "Scanners" },
|
||||||
|
{ id: "gateways", label: "Gateways" },
|
||||||
|
{ id: "issues", label: "Issues" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeTab = computed(() => {
|
||||||
|
const queryTab = String(route.query.tab || "overview");
|
||||||
|
return tabs.some((tab) => tab.id === queryTab) ? queryTab : "overview";
|
||||||
|
});
|
||||||
|
|
||||||
|
const department = computed(() => state.workspace?.department || null);
|
||||||
|
const summary = computed(() => state.workspace?.summary || {});
|
||||||
|
const lanes = computed(() => state.workspace?.lanes || []);
|
||||||
|
const selfServe = computed(() => state.workspace?.self_serve || {});
|
||||||
|
const gates = computed(() => state.workspace?.gates || []);
|
||||||
|
const scanners = computed(() => state.workspace?.scanners || []);
|
||||||
|
const issues = computed(() => state.workspace?.issues || []);
|
||||||
|
const actions = computed(() => state.workspace?.actions || []);
|
||||||
|
|
||||||
|
const loadWorkspace = async ({ resetTransient = true } = {}) => {
|
||||||
|
if (!props.departmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
|
||||||
|
if (resetTransient) {
|
||||||
|
state.notice = null;
|
||||||
|
state.rotatedKeys = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getEdgeGatewayDepartmentWorkspace(props.departmentId);
|
||||||
|
state.workspace = unwrapEdgeGatewayResponse(response, null);
|
||||||
|
state.scannerLaneDrafts = Object.fromEntries(
|
||||||
|
(state.workspace?.scanners || []).map((scanner) => [scanner.id, scanner.lane_id ?? ""])
|
||||||
|
);
|
||||||
|
} catch (requestError) {
|
||||||
|
state.error = normalizeEdgeGatewayError(requestError);
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.departmentId,
|
||||||
|
() => {
|
||||||
|
void loadWorkspace();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTab = async (tabId) => {
|
||||||
|
await router.replace({
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
tab: tabId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPath = async (path) => {
|
||||||
|
if (!path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(path)) {
|
||||||
|
window.location.href = path;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openGatewayPage = async (gatewayId) => {
|
||||||
|
if (!gatewayId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPrimaryGatewayInventory = async () => {
|
||||||
|
const gatewayId = summary.value?.primary_gateway?.id || null;
|
||||||
|
if (!gatewayId) {
|
||||||
|
await router.push("/superuser/configuration/edgegateway");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/inventory`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveScannerLane = async (scanner) => {
|
||||||
|
state.scannerBusy[scanner.id] = true;
|
||||||
|
state.notice = null;
|
||||||
|
state.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateNumberPlateScanner(scanner.id, {
|
||||||
|
department_id: scanner.department_id,
|
||||||
|
name: scanner.name,
|
||||||
|
notes: scanner.notes,
|
||||||
|
lane_id:
|
||||||
|
state.scannerLaneDrafts[scanner.id] === "" || state.scannerLaneDrafts[scanner.id] === null
|
||||||
|
? null
|
||||||
|
: Number(state.scannerLaneDrafts[scanner.id]),
|
||||||
|
});
|
||||||
|
state.notice = {
|
||||||
|
kind: "success",
|
||||||
|
message: `Scanner ${scanner.name} lane assignment updated.`,
|
||||||
|
};
|
||||||
|
await loadWorkspace({ resetTransient: false });
|
||||||
|
await setTab("scanners");
|
||||||
|
} catch (requestError) {
|
||||||
|
state.error = normalizeEdgeGatewayError(requestError);
|
||||||
|
} finally {
|
||||||
|
state.scannerBusy[scanner.id] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateScannerKey = async (scanner) => {
|
||||||
|
state.scannerBusy[scanner.id] = true;
|
||||||
|
state.notice = null;
|
||||||
|
state.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rotateNumberPlateScannerKey(scanner.id);
|
||||||
|
const payload = unwrapEdgeGatewayResponse(response, {});
|
||||||
|
const rotatedKey = payload?.api_key || payload?.scanner?.api_key || null;
|
||||||
|
state.notice = {
|
||||||
|
kind: "success",
|
||||||
|
message: `Scanner ${scanner.name} API key rotated.`,
|
||||||
|
};
|
||||||
|
await loadWorkspace({ resetTransient: false });
|
||||||
|
state.rotatedKeys[scanner.id] = rotatedKey;
|
||||||
|
await setTab("scanners");
|
||||||
|
} catch (requestError) {
|
||||||
|
state.error = normalizeEdgeGatewayError(requestError);
|
||||||
|
} finally {
|
||||||
|
state.scannerBusy[scanner.id] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="department-hardware-workspace" data-testid="department-hardware-workspace">
|
||||||
|
<header class="department-hardware-workspace__header">
|
||||||
|
<div>
|
||||||
|
<p class="department-hardware-workspace__eyebrow">Integrated hardware workspace</p>
|
||||||
|
<h2>{{ department?.name || `Department ${departmentId}` }}</h2>
|
||||||
|
<p>Gateways, lanes, self-serve readiness, gates, scanners, and setup gaps in one department view.</p>
|
||||||
|
</div>
|
||||||
|
<div class="department-hardware-workspace__header-actions">
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
type="button"
|
||||||
|
data-testid="department-hardware-refresh"
|
||||||
|
:disabled="state.loading"
|
||||||
|
@click="loadWorkspace"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-dark"
|
||||||
|
type="button"
|
||||||
|
data-testid="department-hardware-open-fleet"
|
||||||
|
@click="openPath('/superuser/configuration/edgegateway')"
|
||||||
|
>
|
||||||
|
Open Fleet Landing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="state.error" class="notification is-warning" data-testid="department-hardware-error">
|
||||||
|
<strong>{{ state.error.title }}</strong>
|
||||||
|
<p>{{ state.error.message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="state.notice"
|
||||||
|
class="notification"
|
||||||
|
:class="state.notice.kind === 'success' ? 'is-success' : 'is-info'"
|
||||||
|
data-testid="department-hardware-notice"
|
||||||
|
>
|
||||||
|
{{ state.notice.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="state.loading && !state.workspace"
|
||||||
|
class="department-hardware-workspace__empty"
|
||||||
|
data-testid="department-hardware-loading"
|
||||||
|
>
|
||||||
|
Loading hardware workspace...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="state.workspace">
|
||||||
|
<div class="department-hardware-workspace__hero">
|
||||||
|
<article class="department-hardware-workspace__metric" data-testid="department-hardware-primary-gateway">
|
||||||
|
<span>Primary gateway</span>
|
||||||
|
<strong>{{ summary.primary_gateway?.label || "Missing" }}</strong>
|
||||||
|
<small>{{ summary.primary_gateway?.status || "Not configured" }}</small>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__metric" data-testid="department-hardware-transport-mode">
|
||||||
|
<span>Transport mode</span>
|
||||||
|
<strong>{{ summary.transport_mode }}</strong>
|
||||||
|
<small>{{ summary.online_gateway_count }}/{{ summary.gateway_count }} gateways online</small>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__metric" data-testid="department-hardware-binding-coverage">
|
||||||
|
<span>Binding coverage</span>
|
||||||
|
<strong>{{ summary.bound_relay_count }}/{{ summary.required_relay_count }}</strong>
|
||||||
|
<small>{{ summary.missing_binding_count }} missing</small>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__metric" data-testid="department-hardware-selfserve">
|
||||||
|
<span>Self-serve</span>
|
||||||
|
<strong>{{ selfServe.readiness_state }}</strong>
|
||||||
|
<small>{{ selfServe.ready_lanes }}/{{ selfServe.lane_count }} lanes ready</small>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__metric" data-testid="department-hardware-scanners">
|
||||||
|
<span>Scanners</span>
|
||||||
|
<strong>{{ summary.assigned_scanner_count }}/{{ summary.scanner_count }}</strong>
|
||||||
|
<small>assigned to default lanes</small>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__metric" data-testid="department-hardware-issues">
|
||||||
|
<span>Issues</span>
|
||||||
|
<strong>{{ summary.issue_count }}</strong>
|
||||||
|
<small>{{ summary.health }}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="department-hardware-workspace__tabs" data-testid="department-hardware-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="department-hardware-workspace__tab"
|
||||||
|
:class="{ 'is-active': activeTab === tab.id }"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`department-hardware-tab-${tab.id}`"
|
||||||
|
@click="setTab(tab.id)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="activeTab === 'overview'"
|
||||||
|
class="department-hardware-workspace__panel"
|
||||||
|
data-testid="department-hardware-panel-overview"
|
||||||
|
>
|
||||||
|
<div class="department-hardware-workspace__overview-grid">
|
||||||
|
<article class="department-hardware-workspace__card">
|
||||||
|
<h3>Prioritized issues</h3>
|
||||||
|
<div v-if="issues.length === 0" class="department-hardware-workspace__empty">No current workspace issues.</div>
|
||||||
|
<ul v-else class="department-hardware-workspace__list">
|
||||||
|
<li v-for="issue in issues.slice(0, 5)" :key="`${issue.code}-${issue.target_id || 'global'}`">
|
||||||
|
<strong>{{ issue.severity }}</strong> {{ issue.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__card">
|
||||||
|
<h3>Suggested actions</h3>
|
||||||
|
<div v-if="actions.length === 0" class="department-hardware-workspace__empty">No suggested follow-up actions.</div>
|
||||||
|
<div v-else class="department-hardware-workspace__button-list">
|
||||||
|
<button
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.code"
|
||||||
|
class="button is-light"
|
||||||
|
type="button"
|
||||||
|
@click="openPath(action.path)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__card">
|
||||||
|
<h3>Gate transport mix</h3>
|
||||||
|
<p>{{ summary.gate_transport_mix?.relay || 0 }} relay-backed gates</p>
|
||||||
|
<p>{{ summary.gate_transport_mix?.phone_call || 0 }} phone-call gates</p>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__card">
|
||||||
|
<h3>Recent scanner activity</h3>
|
||||||
|
<div v-if="!summary.recent_scan_at" class="department-hardware-workspace__empty">No recent license plate scans recorded.</div>
|
||||||
|
<p v-else>{{ summary.recent_scan_at }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else-if="activeTab === 'lanes'"
|
||||||
|
class="department-hardware-workspace__panel"
|
||||||
|
data-testid="department-hardware-panel-lanes"
|
||||||
|
>
|
||||||
|
<div class="department-hardware-workspace__panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>Lanes and self-serve</h3>
|
||||||
|
<p>{{ selfServe.configured_task_count }} configured tasks across {{ selfServe.configured_product_count }} products.</p>
|
||||||
|
</div>
|
||||||
|
<div class="department-hardware-workspace__button-list">
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
type="button"
|
||||||
|
data-testid="department-hardware-open-selfserve-studio"
|
||||||
|
@click="openPath(selfServe.links?.studio)"
|
||||||
|
>
|
||||||
|
Open Self-Serve Studio
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
type="button"
|
||||||
|
data-testid="department-hardware-open-binding-inventory"
|
||||||
|
@click="openPrimaryGatewayInventory"
|
||||||
|
>
|
||||||
|
Open Binding Inventory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-for="lane in lanes" :key="lane.id" class="department-hardware-workspace__row" :data-testid="`department-lane-${lane.id}`">
|
||||||
|
<div class="department-hardware-workspace__row-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ lane.name }}</strong>
|
||||||
|
<p>Status: {{ lane.status }} - Machine type: {{ lane.machine_type_id || "Unassigned" }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="department-hardware-workspace__badge" :data-state="lane.binding_coverage.state">
|
||||||
|
{{ lane.binding_coverage.state }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>Products: {{ lane.self_serve_products.length ? lane.self_serve_products.join(", ") : "No products configured" }}</p>
|
||||||
|
<div class="department-hardware-workspace__list-grid">
|
||||||
|
<div
|
||||||
|
v-for="slot in lane.relay_slots"
|
||||||
|
:key="`${lane.id}-${slot.slot}`"
|
||||||
|
class="department-hardware-workspace__detail"
|
||||||
|
>
|
||||||
|
<strong>{{ slot.slot }}</strong>
|
||||||
|
<span>{{ slot.relay_id }}</span>
|
||||||
|
<small>{{ slot.coverage.covered ? slot.coverage.primary_binding?.gateway_label || "Bound" : "Missing binding" }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="department-hardware-workspace__button-list">
|
||||||
|
<button
|
||||||
|
class="button is-light is-small"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`department-lane-open-studio-${lane.id}`"
|
||||||
|
@click="openPath(lane.links?.self_serve_studio)"
|
||||||
|
>
|
||||||
|
Open Studio
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-light is-small"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`department-lane-open-legacy-${lane.id}`"
|
||||||
|
@click="openPath(lane.links?.legacy)"
|
||||||
|
>
|
||||||
|
Open Legacy Lane
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else-if="activeTab === 'gates'"
|
||||||
|
class="department-hardware-workspace__panel"
|
||||||
|
data-testid="department-hardware-panel-gates"
|
||||||
|
>
|
||||||
|
<div class="department-hardware-workspace__panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>Gates</h3>
|
||||||
|
<p>Phone-call and relay-backed gates share the same department transport context.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
type="button"
|
||||||
|
data-testid="department-hardware-open-legacy-gates"
|
||||||
|
@click="openPath('/superuser/department/gates')"
|
||||||
|
>
|
||||||
|
Open Legacy Gates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-for="gate in gates" :key="gate.id" class="department-hardware-workspace__row" :data-testid="`department-gate-${gate.id}`">
|
||||||
|
<div class="department-hardware-workspace__row-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ gate.name }}</strong>
|
||||||
|
<p>
|
||||||
|
{{ gate.transport_type }}
|
||||||
|
<span v-if="gate.is_entrance"> - entrance</span>
|
||||||
|
<span v-if="gate.is_exit"> - exit</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="department-hardware-workspace__badge" :data-state="gate.config_complete ? 'READY' : 'MISSING'">
|
||||||
|
{{ gate.config_complete ? "Configured" : "Incomplete" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="gate.transport_type === 'PHONE_CALL'">
|
||||||
|
{{ gate.config.phone_number || "Missing phone number" }} - threshold {{ gate.config.call_duration_threshold ?? "n/a" }}
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
Relay {{ gate.relay?.relay_id || gate.config.relay_id || "Missing" }} -
|
||||||
|
{{ gate.coverage?.covered ? gate.coverage.primary_binding?.gateway_label || "Bound" : "Missing binding" }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else-if="activeTab === 'scanners'"
|
||||||
|
class="department-hardware-workspace__panel"
|
||||||
|
data-testid="department-hardware-panel-scanners"
|
||||||
|
>
|
||||||
|
<div class="department-hardware-workspace__panel-header">
|
||||||
|
<div>
|
||||||
|
<h3>License plate scanners</h3>
|
||||||
|
<p>Assign each scanner to a default lane and rotate credentials without leaving the workspace.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button is-light"
|
||||||
|
type="button"
|
||||||
|
data-testid="department-hardware-open-legacy-scanners"
|
||||||
|
@click="openPath('/superuser/scanners')"
|
||||||
|
>
|
||||||
|
Open Legacy Scanners
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-for="scanner in scanners" :key="scanner.id" class="department-hardware-workspace__row" :data-testid="`department-scanner-${scanner.id}`">
|
||||||
|
<div class="department-hardware-workspace__row-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ scanner.name }}</strong>
|
||||||
|
<p>{{ scanner.notes || "No notes" }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="department-hardware-workspace__badge" :data-state="scanner.assignment_state">
|
||||||
|
{{ scanner.assignment_state }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="department-hardware-workspace__scanner-grid">
|
||||||
|
<label class="department-hardware-workspace__field">
|
||||||
|
<span>Default lane</span>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select v-model="state.scannerLaneDrafts[scanner.id]" :data-testid="`department-scanner-lane-${scanner.id}`">
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
<option v-for="lane in lanes" :key="lane.id" :value="lane.id">{{ lane.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div class="department-hardware-workspace__detail">
|
||||||
|
<strong>Recent scan</strong>
|
||||||
|
<span>{{ scanner.recent_scan_at || "No recent scans" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="department-hardware-workspace__detail">
|
||||||
|
<strong>Lane coverage</strong>
|
||||||
|
<span>{{ scanner.assigned_lane?.binding_coverage?.state || "Unassigned" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="department-hardware-workspace__button-list">
|
||||||
|
<button
|
||||||
|
class="button is-dark is-small"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`department-scanner-save-${scanner.id}`"
|
||||||
|
:disabled="state.scannerBusy[scanner.id]"
|
||||||
|
@click="saveScannerLane(scanner)"
|
||||||
|
>
|
||||||
|
Save Lane
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-light is-small"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`department-scanner-rotate-${scanner.id}`"
|
||||||
|
:disabled="state.scannerBusy[scanner.id]"
|
||||||
|
@click="rotateScannerKey(scanner)"
|
||||||
|
>
|
||||||
|
Rotate API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-if="state.rotatedKeys[scanner.id]"
|
||||||
|
class="department-hardware-workspace__secret"
|
||||||
|
:data-testid="`department-scanner-key-${scanner.id}`"
|
||||||
|
>{{ state.rotatedKeys[scanner.id] }}</pre>
|
||||||
|
<ul v-if="scanner.recent_scans?.length" class="department-hardware-workspace__list">
|
||||||
|
<li v-for="scan in scanner.recent_scans" :key="scan.id">{{ scan.created_at }} - {{ scan.plate }}</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-else-if="activeTab === 'gateways'"
|
||||||
|
class="department-hardware-workspace__panel"
|
||||||
|
data-testid="department-hardware-panel-gateways"
|
||||||
|
>
|
||||||
|
<EdgeGatewayManager
|
||||||
|
:department-id="departmentId"
|
||||||
|
:route-driven="false"
|
||||||
|
:allow-destructive="false"
|
||||||
|
@open-gateway-page="openGatewayPage"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="department-hardware-workspace__panel" data-testid="department-hardware-panel-issues">
|
||||||
|
<div class="department-hardware-workspace__overview-grid">
|
||||||
|
<article class="department-hardware-workspace__card">
|
||||||
|
<h3>Issues</h3>
|
||||||
|
<div v-if="issues.length === 0" class="department-hardware-workspace__empty">No issues detected.</div>
|
||||||
|
<ul v-else class="department-hardware-workspace__list">
|
||||||
|
<li v-for="issue in issues" :key="`${issue.code}-${issue.target_id || 'global'}`">
|
||||||
|
<strong>{{ issue.severity }}</strong> {{ issue.message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="department-hardware-workspace__card">
|
||||||
|
<h3>Actions</h3>
|
||||||
|
<div class="department-hardware-workspace__button-list">
|
||||||
|
<button
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.code"
|
||||||
|
class="button is-light"
|
||||||
|
type="button"
|
||||||
|
@click="openPath(action.path)"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.department-hardware-workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__header h2 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: #7c2d12;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__header-actions,
|
||||||
|
.department-hardware-workspace__button-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__metric,
|
||||||
|
.department-hardware-workspace__card,
|
||||||
|
.department-hardware-workspace__panel,
|
||||||
|
.department-hardware-workspace__row {
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #dbe4ea;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__metric {
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__metric span,
|
||||||
|
.department-hardware-workspace__metric small {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__metric strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__tab {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__tab.is-active {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__panel {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__overview-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__card,
|
||||||
|
.department-hardware-workspace__row {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__card h3,
|
||||||
|
.department-hardware-workspace__panel-header h3 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__card p,
|
||||||
|
.department-hardware-workspace__panel-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__row-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__row-header p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__badge {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__badge[data-state="READY"],
|
||||||
|
.department-hardware-workspace__badge[data-state="Configured"] {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__badge[data-state="PARTIAL"],
|
||||||
|
.department-hardware-workspace__badge[data-state="MISSING"] {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__badge[data-state="UNASSIGNED"],
|
||||||
|
.department-hardware-workspace__badge[data-state="INVALID"] {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__list-grid,
|
||||||
|
.department-hardware-workspace__scanner-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__detail,
|
||||||
|
.department-hardware-workspace__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__detail span,
|
||||||
|
.department-hardware-workspace__detail small,
|
||||||
|
.department-hardware-workspace__field span {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__secret {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #f8fafc;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.department-hardware-workspace__empty {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.department-hardware-workspace__header,
|
||||||
|
.department-hardware-workspace__panel-header,
|
||||||
|
.department-hardware-workspace__row-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
listEdgeGatewayDepartmentWorkspaces,
|
||||||
|
unwrapEdgeGatewayResponse,
|
||||||
|
} from "@/services/edgeGateways.js";
|
||||||
|
import { normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref(null);
|
||||||
|
const summaries = ref([]);
|
||||||
|
|
||||||
|
const totals = computed(() => {
|
||||||
|
const rows = Array.isArray(summaries.value) ? summaries.value : [];
|
||||||
|
|
||||||
|
return rows.reduce(
|
||||||
|
(accumulator, summary) => {
|
||||||
|
accumulator.departments += 1;
|
||||||
|
accumulator.gateways += Number(summary?.gateway_count || 0);
|
||||||
|
accumulator.onlineGateways += Number(summary?.online_gateway_count || 0);
|
||||||
|
accumulator.missingBindings += Number(summary?.missing_binding_count || 0);
|
||||||
|
accumulator.scanners += Number(summary?.scanner_count || 0);
|
||||||
|
accumulator.assignedScanners += Number(summary?.assigned_scanner_count || 0);
|
||||||
|
accumulator.selfServeReady += Number(summary?.self_serve_ready_lanes || 0);
|
||||||
|
accumulator.selfServeLanes += Number(summary?.lane_count || 0);
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
departments: 0,
|
||||||
|
gateways: 0,
|
||||||
|
onlineGateways: 0,
|
||||||
|
missingBindings: 0,
|
||||||
|
scanners: 0,
|
||||||
|
assignedScanners: 0,
|
||||||
|
selfServeReady: 0,
|
||||||
|
selfServeLanes: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSummaries = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await listEdgeGatewayDepartmentWorkspaces();
|
||||||
|
summaries.value = unwrapEdgeGatewayResponse(response, []);
|
||||||
|
} catch (requestError) {
|
||||||
|
error.value = normalizeEdgeGatewayError(requestError);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDepartmentWorkspace = async (departmentId) => {
|
||||||
|
await router.push(`/superuser/departments/${encodeURIComponent(String(departmentId))}/gateways`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPrimaryGateway = async (gatewayId) => {
|
||||||
|
if (!gatewayId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadSummaries);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="hardware-fleet-landing" data-testid="hardware-fleet-landing">
|
||||||
|
<header class="hardware-fleet-landing__header">
|
||||||
|
<div>
|
||||||
|
<p class="hardware-fleet-landing__eyebrow">Department hardware</p>
|
||||||
|
<h2>Fleet landing</h2>
|
||||||
|
<p>Coverage, setup gaps, and maintenance signals across gateways, lanes, gates, self-serve, and scanners.</p>
|
||||||
|
</div>
|
||||||
|
<button class="button is-light" type="button" :disabled="loading" @click="loadSummaries">Refresh</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="error" class="notification is-warning" data-testid="hardware-fleet-error">
|
||||||
|
<strong>{{ error.title }}</strong>
|
||||||
|
<p>{{ error.message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hardware-fleet-landing__metrics">
|
||||||
|
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-departments">
|
||||||
|
<span>Departments</span>
|
||||||
|
<strong>{{ totals.departments }}</strong>
|
||||||
|
<small>{{ totals.gateways }} gateways</small>
|
||||||
|
</article>
|
||||||
|
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-health">
|
||||||
|
<span>Gateway Health</span>
|
||||||
|
<strong>{{ totals.onlineGateways }}/{{ totals.gateways }}</strong>
|
||||||
|
<small>online now</small>
|
||||||
|
</article>
|
||||||
|
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-bindings">
|
||||||
|
<span>Binding Gaps</span>
|
||||||
|
<strong>{{ totals.missingBindings }}</strong>
|
||||||
|
<small>missing relay mappings</small>
|
||||||
|
</article>
|
||||||
|
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-scanners">
|
||||||
|
<span>Scanners</span>
|
||||||
|
<strong>{{ totals.assignedScanners }}/{{ totals.scanners }}</strong>
|
||||||
|
<small>assigned to lanes</small>
|
||||||
|
</article>
|
||||||
|
<article class="hardware-fleet-landing__metric" data-testid="hardware-fleet-card-selfserve">
|
||||||
|
<span>Self-Serve</span>
|
||||||
|
<strong>{{ totals.selfServeReady }}/{{ totals.selfServeLanes }}</strong>
|
||||||
|
<small>lanes ready</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="hardware-fleet-landing__empty" data-testid="hardware-fleet-loading">Loading department hardware state...</div>
|
||||||
|
<div
|
||||||
|
v-else-if="summaries.length === 0"
|
||||||
|
class="hardware-fleet-landing__empty"
|
||||||
|
data-testid="hardware-fleet-empty"
|
||||||
|
>
|
||||||
|
No department hardware workspaces were returned.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="hardware-fleet-landing__list">
|
||||||
|
<article
|
||||||
|
v-for="summary in summaries"
|
||||||
|
:key="summary.department_id"
|
||||||
|
class="hardware-fleet-landing__row"
|
||||||
|
:data-testid="`hardware-fleet-department-${summary.department_id}`"
|
||||||
|
>
|
||||||
|
<div class="hardware-fleet-landing__summary">
|
||||||
|
<div>
|
||||||
|
<p class="hardware-fleet-landing__department">{{ summary.department_name }}</p>
|
||||||
|
<p class="hardware-fleet-landing__meta">
|
||||||
|
{{ summary.transport_mode }} mode
|
||||||
|
<span>•</span>
|
||||||
|
{{ summary.gateway_count }} gateway<span v-if="summary.gateway_count !== 1">s</span>
|
||||||
|
<span>•</span>
|
||||||
|
{{ summary.issue_count }} issue<span v-if="summary.issue_count !== 1">s</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="hardware-fleet-landing__health" :data-state="summary.health">{{ summary.health }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hardware-fleet-landing__stats">
|
||||||
|
<span>{{ summary.online_gateway_count }}/{{ summary.gateway_count }} online</span>
|
||||||
|
<span>{{ summary.bound_relay_count }}/{{ summary.required_relay_count }} relays bound</span>
|
||||||
|
<span>{{ summary.assigned_scanner_count }}/{{ summary.scanner_count }} scanners assigned</span>
|
||||||
|
<span>{{ summary.self_serve_ready_lanes }}/{{ summary.lane_count }} lanes ready</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hardware-fleet-landing__actions">
|
||||||
|
<button
|
||||||
|
class="button is-dark is-small"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`hardware-fleet-open-workspace-${summary.department_id}`"
|
||||||
|
@click="openDepartmentWorkspace(summary.department_id)"
|
||||||
|
>
|
||||||
|
Open Workspace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="summary.primary_gateway?.id"
|
||||||
|
class="button is-light is-small"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`hardware-fleet-open-primary-${summary.department_id}`"
|
||||||
|
@click="openPrimaryGateway(summary.primary_gateway.id)"
|
||||||
|
>
|
||||||
|
Open Primary Gateway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hardware-fleet-landing {
|
||||||
|
border: 1px solid #dbe4ea;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__header h2 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7c2d12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__metrics {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__metric {
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 0.9rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__metric span,
|
||||||
|
.hardware-fleet-landing__metric small {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__metric strong {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__row {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__department {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__meta {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__health {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__health[data-state="READY"] {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__health[data-state="PARTIAL"] {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__health[data-state="AT_RISK"] {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hardware-fleet-landing__empty {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #64748b;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hardware-fleet-landing__header,
|
||||||
|
.hardware-fleet-landing__summary {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -175,7 +175,7 @@ const deleteGateway = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="edge-gateway-manage" data-testid="gateway-manage-page">
|
<section class="edge-gateway-manage" data-testid="gateway-settings-page">
|
||||||
<article class="edge-gateway-manage__panel">
|
<article class="edge-gateway-manage__panel">
|
||||||
<p class="edge-gateway-manage__eyebrow">Metadata</p>
|
<p class="edge-gateway-manage__eyebrow">Metadata</p>
|
||||||
<h3>Gateway identity</h3>
|
<h3>Gateway identity</h3>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,21 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
showOpenFullPage: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
defineEmits(["open-full-page"]);
|
|
||||||
|
|
||||||
const formatValue = (value, suffix = "") => (Number.isFinite(Number(value)) ? `${Number(value)}${suffix}` : "No data");
|
const formatValue = (value, suffix = "") => (Number.isFinite(Number(value)) ? `${Number(value)}${suffix}` : "No data");
|
||||||
const formatDate = (value) => (value ? String(value) : "No data");
|
const formatDate = (value) => (value ? String(value) : "No data");
|
||||||
|
const formatUpdateWindow = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "No window set";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.window || value.value || "No window set";
|
||||||
|
};
|
||||||
const containerServices = computed(() => props.gateway?.container_health?.services || []);
|
const containerServices = computed(() => props.gateway?.container_health?.services || []);
|
||||||
const diagnostics = computed(() => props.gateway?.diagnostics || []);
|
const diagnostics = computed(() => props.gateway?.diagnostics || []);
|
||||||
const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.gateway?.agent_runtime?.system_metrics || {});
|
const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.gateway?.agent_runtime?.system_metrics || {});
|
||||||
@@ -22,17 +28,6 @@ const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="edge-gateway-overview" data-testid="gateway-overview-page">
|
<section class="edge-gateway-overview" data-testid="gateway-overview-page">
|
||||||
<div v-if="showOpenFullPage" class="edge-gateway-overview__actions">
|
|
||||||
<button
|
|
||||||
class="button is-light"
|
|
||||||
type="button"
|
|
||||||
data-testid="gateway-open-full-page"
|
|
||||||
@click="$emit('open-full-page')"
|
|
||||||
>
|
|
||||||
Open full page
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="edge-gateway-overview__grid">
|
<div class="edge-gateway-overview__grid">
|
||||||
<article class="edge-gateway-overview__card">
|
<article class="edge-gateway-overview__card">
|
||||||
<p class="edge-gateway-overview__eyebrow">Gateway status</p>
|
<p class="edge-gateway-overview__eyebrow">Gateway status</p>
|
||||||
@@ -54,7 +49,7 @@ const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.
|
|||||||
|
|
||||||
<article class="edge-gateway-overview__card" data-testid="gateway-overview-update-window">
|
<article class="edge-gateway-overview__card" data-testid="gateway-overview-update-window">
|
||||||
<p class="edge-gateway-overview__eyebrow">Update window</p>
|
<p class="edge-gateway-overview__eyebrow">Update window</p>
|
||||||
<h3>{{ gateway.update_window || "No window set" }}</h3>
|
<h3>{{ formatUpdateWindow(gateway.update_window) }}</h3>
|
||||||
<p>Last sync {{ formatDate(gateway.last_sync_at) }}</p>
|
<p>Last sync {{ formatDate(gateway.last_sync_at) }}</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -121,11 +116,6 @@ const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edge-gateway-overview__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edge-gateway-overview__grid {
|
.edge-gateway-overview__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
@@ -33,7 +33,27 @@ const cards = computed(() => {
|
|||||||
|
|
||||||
const channelStatus = computed(() => props.statistics?.channel_status || {});
|
const channelStatus = computed(() => props.statistics?.channel_status || {});
|
||||||
const transportHealth = computed(() => props.statistics?.transport_health || {});
|
const transportHealth = computed(() => props.statistics?.transport_health || {});
|
||||||
const versionDrift = computed(() => props.statistics?.version_drift || {});
|
const gatewaySnapshot = computed(() => props.statistics?.gateway || {});
|
||||||
|
const versionDrift = computed(() => {
|
||||||
|
const fallback = props.statistics?.version_drift || {};
|
||||||
|
const gateway = gatewaySnapshot.value || {};
|
||||||
|
const installedVersion =
|
||||||
|
gateway.installed_version || gateway.agent_runtime?.installed_version || fallback.installed_version || null;
|
||||||
|
const targetVersion =
|
||||||
|
gateway.staged_version?.target_version ||
|
||||||
|
gateway.target_version ||
|
||||||
|
gateway.agent_runtime?.target_version ||
|
||||||
|
fallback.target_version ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
installed_version: installedVersion,
|
||||||
|
target_version: targetVersion,
|
||||||
|
is_drifted:
|
||||||
|
installedVersion && targetVersion ? installedVersion !== targetVersion : Boolean(fallback.is_drifted),
|
||||||
|
};
|
||||||
|
});
|
||||||
const fleetUsage = computed(() => props.statistics?.fleet_usage || {});
|
const fleetUsage = computed(() => props.statistics?.fleet_usage || {});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
operations: {
|
operations: {
|
||||||
@@ -72,6 +72,32 @@ const streamLabel = computed(() =>
|
|||||||
idle: "Idle",
|
idle: "Idle",
|
||||||
}[String(props.streamStatus || "").toLowerCase()] || "Idle")
|
}[String(props.streamStatus || "").toLowerCase()] || "Idle")
|
||||||
);
|
);
|
||||||
|
const localTargetVersion = ref("");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.updateTargetVersion,
|
||||||
|
(value, previousValue) => {
|
||||||
|
const normalizedValue = String(value || "");
|
||||||
|
const normalizedPreviousValue = String(previousValue || "");
|
||||||
|
|
||||||
|
if (
|
||||||
|
!String(localTargetVersion.value || "").trim() ||
|
||||||
|
String(localTargetVersion.value || "") === normalizedPreviousValue
|
||||||
|
) {
|
||||||
|
localTargetVersion.value = normalizedValue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTargetVersionInput = (value) => {
|
||||||
|
localTargetVersion.value = String(value || "");
|
||||||
|
emit("update-target-version", localTargetVersion.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueUpdate = () => {
|
||||||
|
emit("queue-update", localTargetVersion.value.trim());
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -107,11 +133,11 @@ const streamLabel = computed(() =>
|
|||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
data-testid="gateway-update-target-version"
|
data-testid="gateway-update-target-version"
|
||||||
:value="updateTargetVersion"
|
:value="localTargetVersion"
|
||||||
placeholder="Target version"
|
placeholder="Target version"
|
||||||
@input="emit('update-target-version', $event.target.value)"
|
@input="updateTargetVersionInput($event.target.value)"
|
||||||
/>
|
/>
|
||||||
<button class="button is-dark" type="button" data-testid="gateway-operation-update" :disabled="busy || !allowDestructive" @click="emit('queue-update')">
|
<button class="button is-dark" type="button" data-testid="gateway-operation-update" :disabled="busy || !allowDestructive" @click="queueUpdate">
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+159
-151
@@ -7,173 +7,181 @@ import authMiddleware from './middleware/authMiddleware.js';
|
|||||||
import guestMiddleware from "@/middleware/guestMiddleware.js";
|
import guestMiddleware from "@/middleware/guestMiddleware.js";
|
||||||
import adminMiddleware from './middleware/adminMiddleware.js';
|
import adminMiddleware from './middleware/adminMiddleware.js';
|
||||||
import superUserMiddleware from "@/middleware/superUserMiddleware.js";
|
import superUserMiddleware from "@/middleware/superUserMiddleware.js";
|
||||||
|
|
||||||
|
const lazyModules = import.meta.glob([
|
||||||
|
'/src/views/**/*.vue',
|
||||||
|
'!/src/views/**/types/*.vue',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function lazyView(importPath) {
|
||||||
|
let normalizedPath = importPath;
|
||||||
|
if (importPath.startsWith('@/')) {
|
||||||
|
normalizedPath = '/src/' + importPath.slice(2);
|
||||||
|
} else if (importPath.startsWith('./')) {
|
||||||
|
normalizedPath = '/src/' + importPath.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = lazyModules[normalizedPath];
|
||||||
|
|
||||||
|
if (!loader) {
|
||||||
|
throw new Error(`Unable to lazy load route component: ${importPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loader;
|
||||||
|
}
|
||||||
/** Views */
|
/** Views */
|
||||||
/**
|
/**
|
||||||
* Default page
|
* Default page
|
||||||
*/
|
*/
|
||||||
import DefaultPage from "@/views/DefaultPage.vue";
|
const DefaultPage = lazyView('@/views/DefaultPage.vue');
|
||||||
/** Auth */
|
/** Auth */
|
||||||
import Login from './views/auth/Login.vue';
|
const Login = lazyView('./views/auth/Login.vue');
|
||||||
import Register from './views/auth/Register.vue';
|
const Register = lazyView('./views/auth/Register.vue');
|
||||||
import LoginQR from "@/views/auth/LoginQR.vue";
|
const LoginQR = lazyView('@/views/auth/LoginQR.vue');
|
||||||
/** Dashboards */
|
/** Dashboards */
|
||||||
import SuperUserDashboard from './views/dashboards/SuperUserDashboard.vue';
|
const SuperUserDashboard = lazyView('./views/dashboards/SuperUserDashboard.vue');
|
||||||
import DepartmentDashboard from './views/dashboards/DepartmentDashboard.vue';
|
const DepartmentDashboard = lazyView('./views/dashboards/DepartmentDashboard.vue');
|
||||||
import UserDashboard from './views/dashboards/UserDashboard.vue';
|
const UserDashboard = lazyView('./views/dashboards/UserDashboard.vue');
|
||||||
|
|
||||||
/** Dashboard: SuperUser */
|
/** Dashboard: SuperUser */
|
||||||
import Departments from "@/views/dashboards/superUserDashboard/Departments.vue";
|
const Departments = lazyView('@/views/dashboards/superUserDashboard/Departments.vue');
|
||||||
import DepartmentLanes from "@/views/dashboards/superUserDashboard/DepartmentLanes.vue";
|
const DepartmentLanes = lazyView('@/views/dashboards/superUserDashboard/DepartmentLanes.vue');
|
||||||
import DepartmentGates from "@/views/dashboards/superUserDashboard/DepartmentGates.vue";
|
const DepartmentGates = lazyView('@/views/dashboards/superUserDashboard/DepartmentGates.vue');
|
||||||
import DepartmentRelays from "@/views/dashboards/superUserDashboard/DepartmentRelays.vue";
|
const DepartmentRelays = lazyView('@/views/dashboards/superUserDashboard/DepartmentRelays.vue');
|
||||||
import DepartmentLane from "@/views/dashboards/superUserDashboard/department/lanes/DepartmentLane.vue";
|
const DepartmentLane = lazyView('@/views/dashboards/superUserDashboard/department/lanes/DepartmentLane.vue');
|
||||||
import Users from "@/views/dashboards/superUserDashboard/Users.vue";
|
const Users = lazyView('@/views/dashboards/superUserDashboard/Users.vue');
|
||||||
import Products from "@/views/dashboards/superUserDashboard/Products.vue";
|
const Products = lazyView('@/views/dashboards/superUserDashboard/Products.vue');
|
||||||
import Orders from "@/views/dashboards/superUserDashboard/Orders.vue";
|
const Orders = lazyView('@/views/dashboards/superUserDashboard/Orders.vue');
|
||||||
import OrdersDrafts from "@/views/dashboards/superUserDashboard/OrdersDrafts.vue";
|
const OrdersDrafts = lazyView('@/views/dashboards/superUserDashboard/OrdersDrafts.vue');
|
||||||
import Bookings from "@/views/dashboards/superUserDashboard/Bookings.vue";
|
const Bookings = lazyView('@/views/dashboards/superUserDashboard/Bookings.vue');
|
||||||
import NumberPlateScanners from "@/views/dashboards/superUserDashboard/NumberPlateScanners.vue";
|
const NumberPlateScanners = lazyView('@/views/dashboards/superUserDashboard/NumberPlateScanners.vue');
|
||||||
import Statistics from "@/views/dashboards/superUserDashboard/statistics/Statistics.vue";
|
const Statistics = lazyView('@/views/dashboards/superUserDashboard/statistics/Statistics.vue');
|
||||||
import DepartmentSelfServe from "@/views/dashboards/superUserDashboard/DepartmentSelfServe.vue";
|
const DepartmentSelfServe = lazyView('@/views/dashboards/superUserDashboard/DepartmentSelfServe.vue');
|
||||||
import EdgeGatewaysWorkspacePage from "@/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue";
|
const EdgeGatewaysWorkspacePage = lazyView('@/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue');
|
||||||
import Invoicing from "@/views/dashboards/superUserDashboard/Invoicing.vue";
|
const Invoicing = lazyView('@/views/dashboards/superUserDashboard/Invoicing.vue');
|
||||||
|
|
||||||
/** Dashboard: Department */
|
/** Dashboard: Department */
|
||||||
import DepartmentPos from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPos.vue";
|
const DepartmentPos = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPos.vue');
|
||||||
import DepartmentPosDrafts from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosDrafts.vue";
|
const DepartmentPosDrafts = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosDrafts.vue');
|
||||||
import DepartmentPosOrders from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrders.vue";
|
const DepartmentPosOrders = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrders.vue');
|
||||||
import DepartmentPosOrder from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrder.vue";
|
const DepartmentPosOrder = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrder.vue');
|
||||||
import DepartmentPosSyncDuplicates from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSyncDuplicates.vue";
|
const DepartmentPosSyncDuplicates = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSyncDuplicates.vue');
|
||||||
//import DepartmentIntranet from "@/views/dashboards/departmentDashboard/modules/intranet/DepartmentIntranet.vue";
|
//const DepartmentIntranet = lazyView('@/views/dashboards/departmentDashboard/modules/intranet/DepartmentIntranet.vue');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard: User
|
* Dashboard: User
|
||||||
*/
|
*/
|
||||||
import MyVehicles from "@/views/dashboards/userDashboard/vehicles/MyVehicles.vue";
|
const MyVehicles = lazyView('@/views/dashboards/userDashboard/vehicles/MyVehicles.vue');
|
||||||
import MyMaterials from "@/views/dashboards/userDashboard/materials/MyMaterials.vue";
|
const MyMaterials = lazyView('@/views/dashboards/userDashboard/materials/MyMaterials.vue');
|
||||||
|
|
||||||
/** Errors */
|
/** Errors */
|
||||||
import NotFound from './views/errors/NotFound.vue';
|
const NotFound = lazyView('./views/errors/NotFound.vue');
|
||||||
import ConnectivityIssue from './views/errors/ConnectivityIssue.vue';
|
const ConnectivityIssue = lazyView('./views/errors/ConnectivityIssue.vue');
|
||||||
import OutdatedInstallation from "@/views/errors/OutdatedInstallation.vue";
|
const OutdatedInstallation = lazyView('@/views/errors/OutdatedInstallation.vue');
|
||||||
import OutdatedGateway from "@/views/errors/OutdatedGateway.vue";
|
const OutdatedGateway = lazyView('@/views/errors/OutdatedGateway.vue');
|
||||||
import MyOrders from "@/views/dashboards/userDashboard/orders/MyOrders.vue";
|
const MyOrders = lazyView('@/views/dashboards/userDashboard/orders/MyOrders.vue');
|
||||||
import NewVehicle from "@/views/dashboards/userDashboard/vehicles/NewVehicle.vue";
|
const NewVehicle = lazyView('@/views/dashboards/userDashboard/vehicles/NewVehicle.vue');
|
||||||
import MyBookings from "@/views/dashboards/userDashboard/bookings/MyBookings.vue";
|
const MyBookings = lazyView('@/views/dashboards/userDashboard/bookings/MyBookings.vue');
|
||||||
import DatabaseOverview from "@/views/dashboards/superUserDashboard/system/DatabaseOverview.vue";
|
const DatabaseOverview = lazyView('@/views/dashboards/superUserDashboard/system/DatabaseOverview.vue');
|
||||||
import DepartmentBookings from "@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookings.vue";
|
const DepartmentBookings = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookings.vue');
|
||||||
import Employee from "@/views/auth/Employee.vue";
|
const Employee = lazyView('@/views/auth/Employee.vue');
|
||||||
import User from "@/views/dashboards/superUserDashboard/user/User.vue";
|
const User = lazyView('@/views/dashboards/superUserDashboard/user/User.vue');
|
||||||
import UserOrders from "@/views/dashboards/superUserDashboard/user/UserOrders.vue";
|
const UserOrders = lazyView('@/views/dashboards/superUserDashboard/user/UserOrders.vue');
|
||||||
import UserPricing from "@/views/dashboards/superUserDashboard/user/UserPricing.vue";
|
const UserPricing = lazyView('@/views/dashboards/superUserDashboard/user/UserPricing.vue');
|
||||||
import Department from "@/views/dashboards/superUserDashboard/department/Department.vue";
|
const Department = lazyView('@/views/dashboards/superUserDashboard/department/Department.vue');
|
||||||
import DepartmentPricing from "@/views/dashboards/superUserDashboard/department/DepartmentPricing.vue";
|
const DepartmentPricing = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentPricing.vue');
|
||||||
import MyOrder from "@/views/dashboards/userDashboard/orders/MyOrder.vue";
|
const MyOrder = lazyView('@/views/dashboards/userDashboard/orders/MyOrder.vue');
|
||||||
import StatisticsOverview from "@/views/dashboards/superUserDashboard/statistics/StatisticsOverview.vue";
|
const StatisticsOverview = lazyView('@/views/dashboards/superUserDashboard/statistics/StatisticsOverview.vue');
|
||||||
import Configuration from "@/views/dashboards/superUserDashboard/configuration/Configuration.vue";
|
const Configuration = lazyView('@/views/dashboards/superUserDashboard/configuration/Configuration.vue');
|
||||||
import ConfigurationEconomic from "@/views/dashboards/superUserDashboard/configuration/ConfigurationEconomic.vue";
|
const ConfigurationEconomic = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEconomic.vue');
|
||||||
import ConfigurationReCAPTCHA from "@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue";
|
const ConfigurationReCAPTCHA = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue');
|
||||||
import ConfigurationEmail from "@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue";
|
const ConfigurationEmail = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue');
|
||||||
import ConfigurationBackups from "@/views/dashboards/superUserDashboard/configuration/ConfigurationBackups.vue";
|
const ConfigurationBackups = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationBackups.vue');
|
||||||
import Categories from "@/views/dashboards/superUserDashboard/Categories.vue";
|
const Categories = lazyView('@/views/dashboards/superUserDashboard/Categories.vue');
|
||||||
import Customers from "@/views/dashboards/superUserDashboard/Customers.vue";
|
const Customers = lazyView('@/views/dashboards/superUserDashboard/Customers.vue');
|
||||||
import CustomerComplaints from "@/views/dashboards/superUserDashboard/CustomerComplaints.vue";
|
const CustomerComplaints = lazyView('@/views/dashboards/superUserDashboard/CustomerComplaints.vue');
|
||||||
import DepartmentCategories from "@/views/dashboards/superUserDashboard/department/DepartmentCategories.vue";
|
const DepartmentCategories = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentCategories.vue');
|
||||||
import ConfigurationMotorAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationMotorAPI.vue";
|
const ConfigurationMotorAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationMotorAPI.vue');
|
||||||
import ConfigurationStripe from "@/views/dashboards/superUserDashboard/configuration/ConfigurationStripe.vue";
|
const ConfigurationStripe = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationStripe.vue');
|
||||||
import Roles from "@/views/dashboards/superUserDashboard/roles/Roles.vue";
|
const Roles = lazyView('@/views/dashboards/superUserDashboard/roles/Roles.vue');
|
||||||
import SuperUserRolesPermissions from "@/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue";
|
const SuperUserRolesPermissions = lazyView('@/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue');
|
||||||
import DepartmentStripeTerminalsReaders from "@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue";
|
const DepartmentStripeTerminalsReaders = lazyView('@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue');
|
||||||
import DepartmentStripeSetup from "@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue";
|
const DepartmentStripeSetup = lazyView('@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue');
|
||||||
import CollectedOrderInvoices from "@/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue";
|
const CollectedOrderInvoices = lazyView('@/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue');
|
||||||
import CollectedOrderInvoice
|
const CollectedOrderInvoice = lazyView('@/views/dashboards/superUserDashboard/collectedOrderInvoice/collectedOrderInvoice.vue');
|
||||||
from "@/views/dashboards/superUserDashboard/collectedOrderInvoice/collectedOrderInvoice.vue";
|
const InvoiceDistributionMonthView = lazyView('@/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue');
|
||||||
import InvoiceDistributionMonthView
|
const DepartmentDailyReport = lazyView('@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue');
|
||||||
from "@/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue";
|
const SuperUserDashboardProduct = lazyView('@/views/dashboards/superUserDashboard/products/SuperUserDashboardProduct.vue');
|
||||||
import DepartmentDailyReport
|
const UserOther = lazyView('@/views/dashboards/superUserDashboard/user/UserOther.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
|
//const MyBookingsNew = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsNew.vue');
|
||||||
import SuperUserDashboardProduct from "@/views/dashboards/superUserDashboard/products/SuperUserDashboardProduct.vue";
|
const DepartmentCompleteBooking = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentCompleteBooking.vue');
|
||||||
import UserOther from "@/views/dashboards/superUserDashboard/user/UserOther.vue";
|
const GuestHome = lazyView('@/views/guest/GuestHome.vue');
|
||||||
//import MyBookingsNew from "@/views/dashboards/userDashboard/bookings/MyBookingsNew.vue";
|
const GuestBookExteriorWash = lazyView('@/views/guest/book/GuestBookExteriorWash.vue');
|
||||||
import DepartmentCompleteBooking
|
const DepartmentProfile = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentProfile.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/bookings/DepartmentCompleteBooking.vue";
|
const DepartmentGatewaysWorkspacePage = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue');
|
||||||
import GuestHome from "@/views/guest/GuestHome.vue";
|
const ConfigurationFXRatesAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue');
|
||||||
import GuestBookExteriorWash from "@/views/guest/book/GuestBookExteriorWash.vue";
|
const ConfigurationWeatherAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue');
|
||||||
import DepartmentProfile from "@/views/dashboards/superUserDashboard/department/DepartmentProfile.vue";
|
const ConfigurationWorkfeed = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue');
|
||||||
import DepartmentGatewaysWorkspacePage from "@/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue";
|
const ConfigurationGatewayAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationGatewayAPI.vue');
|
||||||
import ConfigurationFXRatesAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue";
|
const UserWashSubscriptions = lazyView('@/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue');
|
||||||
import ConfigurationWeatherAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue";
|
const UserProfile = lazyView('@/views/dashboards/userDashboard/profile/UserProfile.vue');
|
||||||
import ConfigurationWorkfeed from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue";
|
const DepartmentNotifications = lazyView('@/views/dashboards/departmentDashboard/modules/notifications/DepartmentNotifications.vue');
|
||||||
import ConfigurationGatewayAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationGatewayAPI.vue";
|
const Vehicles = lazyView('@/views/dashboards/superUserDashboard/Vehicles.vue');
|
||||||
import UserWashSubscriptions from "@/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue";
|
const ConfigurationXLVask = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationXLVask.vue');
|
||||||
import UserProfile from "@/views/dashboards/userDashboard/profile/UserProfile.vue";
|
const UserXLVask = lazyView('@/views/dashboards/superUserDashboard/user/UserXLVask.vue');
|
||||||
import DepartmentNotifications
|
const DepartmentTimeBookings = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookings.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/notifications/DepartmentNotifications.vue";
|
const DepartmentTimeBookingsOpeningHours = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue');
|
||||||
import Vehicles from "@/views/dashboards/superUserDashboard/Vehicles.vue";
|
const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsTypes.vue');
|
||||||
import ConfigurationXLVask from "@/views/dashboards/superUserDashboard/configuration/ConfigurationXLVask.vue";
|
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
|
||||||
import UserXLVask from "@/views/dashboards/superUserDashboard/user/UserXLVask.vue";
|
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue');
|
||||||
import DepartmentTimeBookings
|
const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookings.vue";
|
const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue');
|
||||||
import DepartmentTimeBookingsOpeningHours
|
const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue";
|
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
|
||||||
import DepartmentTimeBookingsTypes
|
const DepartmentWashLanes = lazyView('@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLanes.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsTypes.vue";
|
const DepartmentWashLane = lazyView('@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLane.vue');
|
||||||
import DepartmentModulesSetup
|
const DepartmentSelfServeStudio = lazyView('@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue');
|
||||||
from "@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue";
|
const DepartmentGoals = lazyView('@/views/dashboards/departmentDashboard/modules/goals/DepartmentGoals.vue');
|
||||||
import Vehicle from "@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue";
|
const ConfigurationLimble = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationLimble.vue');
|
||||||
import MyInvoices from "@/views/dashboards/userDashboard/invoices/MyInvoices.vue";
|
const MyVehicle = lazyView('@/views/dashboards/userDashboard/vehicles/MyVehicle.vue');
|
||||||
import MicrosoftCallbackToken from "@/views/callback/microsoft/MicrosoftCallbackToken.vue";
|
const XLVaskPage = lazyView('@/views/dashboards/superUserDashboard/XLVaskPage.vue');
|
||||||
import ConfigurationEntra from "@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue";
|
const XLVaskCustomersPage = lazyView('@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskCustomersPage.vue');
|
||||||
import DepartmentTimeBookingsNew
|
const XLVaskUsageLogsPage = lazyView('@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskUsageLogsPage.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue";
|
const DepartmentPosSync = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue');
|
||||||
import DepartmentWashLanes from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLanes.vue";
|
const PWADownload = lazyView('@/views/guest/pwa/PWADownload.vue');
|
||||||
import DepartmentWashLane from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLane.vue";
|
const Logout = lazyView('@/views/auth/Logout.vue');
|
||||||
import DepartmentSelfServeStudio
|
//const ReaderReliabilityTest = lazyView('@/views/dashboards/departmentDashboard/modules/debug/ReaderReliabilityTest.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue";
|
const ConfigurationOcrSpace = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationOcrSpace.vue');
|
||||||
import DepartmentGoals
|
const ConfigurationOpenAI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationOpenAI.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/goals/DepartmentGoals.vue";
|
const ConfigurationLicensePlateRecognizer = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationLicensePlateRecognizer.vue');
|
||||||
import ConfigurationLimble from "@/views/dashboards/superUserDashboard/configuration/ConfigurationLimble.vue";
|
const ConfigurationVirkData = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationVirkData.vue');
|
||||||
import MyVehicle from "@/views/dashboards/userDashboard/vehicles/MyVehicle.vue";
|
const ConfigurationShelly = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationShelly.vue');
|
||||||
import XLVaskPage from "@/views/dashboards/superUserDashboard/XLVaskPage.vue";
|
const MyBookingsBook = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsBook.vue');
|
||||||
import XLVaskCustomersPage from "@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskCustomersPage.vue";
|
const ReturnToOwnAccount = lazyView('@/views/auth/ReturnToOwnAccount.vue');
|
||||||
import XLVaskUsageLogsPage from "@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskUsageLogsPage.vue";
|
const DepartmentDashboardOrderBooking = lazyView('@/views/dashboards/departmentDashboard/modules/order-bookings/DepartmentDashboardOrderBooking.vue');
|
||||||
import DepartmentPosSync from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue";
|
const DepartmentBookingsLegacy = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookingsLegacy.vue');
|
||||||
import PWADownload from "@/views/guest/pwa/PWADownload.vue";
|
const MyBookingsLegacy = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsLegacy.vue');
|
||||||
import Logout from "@/views/auth/Logout.vue";
|
const ConfigurationSelfServe = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationSelfServe.vue');
|
||||||
//import ReaderReliabilityTest from "@/views/dashboards/departmentDashboard/modules/debug/ReaderReliabilityTest.vue";
|
const ConfigurationBird = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationBird.vue');
|
||||||
import ConfigurationOcrSpace from "@/views/dashboards/superUserDashboard/configuration/ConfigurationOcrSpace.vue";
|
const MyWash = lazyView('@/views/dashboards/userDashboard/wash/MyWash.vue');
|
||||||
import ConfigurationOpenAI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationOpenAI.vue";
|
const MyWashStart = lazyView('@/views/dashboards/userDashboard/wash/MyWashStart.vue');
|
||||||
import ConfigurationLicensePlateRecognizer from "@/views/dashboards/superUserDashboard/configuration/ConfigurationLicensePlateRecognizer.vue";
|
const LandingPage = lazyView('@/views/pages/LandingPage.vue');
|
||||||
import ConfigurationVirkData from "@/views/dashboards/superUserDashboard/configuration/ConfigurationVirkData.vue";
|
const BookDemoPage = lazyView('@/views/pages/BookDemoPage.vue');
|
||||||
import ConfigurationShelly from "@/views/dashboards/superUserDashboard/configuration/ConfigurationShelly.vue";
|
const AboutUsPage = lazyView('@/views/pages/AboutUsPage.vue');
|
||||||
import MyBookingsBook from "@/views/dashboards/userDashboard/bookings/MyBookingsBook.vue";
|
const PrivacyPolicyPage = lazyView('@/views/pages/PrivacyPolicyPage.vue');
|
||||||
import ReturnToOwnAccount from "@/views/auth/ReturnToOwnAccount.vue";
|
const PasswordResetPage = lazyView('@/views/pages/auth/PasswordResetPage.vue');
|
||||||
import DepartmentDashboardOrderBooking
|
const PasswordResetConfirmationPage = lazyView('@/views/pages/auth/PasswordResetConfirmationPage.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/order-bookings/DepartmentDashboardOrderBooking.vue";
|
const SubuserCompleteRegistrationPage = lazyView('@/views/pages/auth/SubuserCompleteRegistrationPage.vue');
|
||||||
import DepartmentBookingsLegacy
|
const GuestQRNewCustomer = lazyView('@/views/guest/qr/GuestQRNewCustomer.vue');
|
||||||
from "@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookingsLegacy.vue";
|
const GuestQrNewDriver = lazyView('@/views/guest/qr/GuestQRNewDriver.vue');
|
||||||
import MyBookingsLegacy from "@/views/dashboards/userDashboard/bookings/MyBookingsLegacy.vue";
|
const GuestQRNewDriver = lazyView('@/views/guest/qr/GuestQRNewDriver.vue');
|
||||||
import ConfigurationSelfServe from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSelfServe.vue";
|
const GuestQRNewBooking = lazyView('@/views/guest/qr/GuestQRNewBooking.vue');
|
||||||
import ConfigurationBird from "@/views/dashboards/superUserDashboard/configuration/ConfigurationBird.vue";
|
const CustomerCreationPage = lazyView('@/views/pages/auth/CustomerCreationPage.vue');
|
||||||
import MyWash from "@/views/dashboards/userDashboard/wash/MyWash.vue";
|
const Subusers = lazyView('@/views/dashboards/superUserDashboard/Subusers.vue');
|
||||||
import MyWashStart from "@/views/dashboards/userDashboard/wash/MyWashStart.vue";
|
const SubuserGrants = lazyView('@/views/dashboards/superUserDashboard/SubuserGrants.vue');
|
||||||
import LandingPage from "@/views/pages/LandingPage.vue";
|
const SubuserLogin = lazyView('@/views/auth/SubuserLogin.vue');
|
||||||
import BookDemoPage from "@/views/pages/BookDemoPage.vue";
|
const SystemSearchRecordPage = lazyView('@/views/search/SystemSearchRecordPage.vue');
|
||||||
import AboutUsPage from "@/views/pages/AboutUsPage.vue";
|
const EconomicQueuePlaywrightHarness = lazyView('@/views/testing/EconomicQueuePlaywrightHarness.vue');
|
||||||
import PrivacyPolicyPage from "@/views/pages/PrivacyPolicyPage.vue";
|
|
||||||
import PasswordResetPage from "@/views/pages/auth/PasswordResetPage.vue";
|
|
||||||
import PasswordResetConfirmationPage from "@/views/pages/auth/PasswordResetConfirmationPage.vue";
|
|
||||||
import SubuserCompleteRegistrationPage from "@/views/pages/auth/SubuserCompleteRegistrationPage.vue";
|
|
||||||
import GuestQRNewCustomer from "@/views/guest/qr/GuestQRNewCustomer.vue";
|
|
||||||
import GuestQrNewDriver from "@/views/guest/qr/GuestQRNewDriver.vue";
|
|
||||||
import GuestQRNewDriver from "@/views/guest/qr/GuestQRNewDriver.vue";
|
|
||||||
import GuestQRNewBooking from "@/views/guest/qr/GuestQRNewBooking.vue";
|
|
||||||
import CustomerCreationPage from "@/views/pages/auth/CustomerCreationPage.vue";
|
|
||||||
import Subusers from "@/views/dashboards/superUserDashboard/Subusers.vue";
|
|
||||||
import SubuserGrants from "@/views/dashboards/superUserDashboard/SubuserGrants.vue";
|
|
||||||
import SubuserLogin from "@/views/auth/SubuserLogin.vue";
|
|
||||||
import SystemSearchRecordPage from "@/views/search/SystemSearchRecordPage.vue";
|
|
||||||
import EconomicQueuePlaywrightHarness from "@/views/testing/EconomicQueuePlaywrightHarness.vue";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meta data for routes
|
* Meta data for routes
|
||||||
|
|||||||
@@ -357,6 +357,12 @@ export const listEdgeGatewayDepartments = async ({ forceRefresh = false } = {})
|
|||||||
return departmentsRequestInFlight;
|
return departmentsRequestInFlight;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listEdgeGatewayDepartmentWorkspaces = async () =>
|
||||||
|
authenticatedRequest("/modules/edge-gateways/workspace/departments", "GET", {});
|
||||||
|
|
||||||
|
export const getEdgeGatewayDepartmentWorkspace = async (departmentId) =>
|
||||||
|
authenticatedRequest(`/modules/edge-gateways/workspace/departments/${encodeURIComponent(String(departmentId))}`, "GET", {});
|
||||||
|
|
||||||
export const listEdgeGateways = async ({ departmentId = null, view = "summary", forceRefresh = false } = {}) => {
|
export const listEdgeGateways = async ({ departmentId = null, view = "summary", forceRefresh = false } = {}) => {
|
||||||
const requestKey = listCacheKey({ departmentId, view });
|
const requestKey = listCacheKey({ departmentId, view });
|
||||||
if (!forceRefresh && listRequestsInFlight.has(requestKey)) {
|
if (!forceRefresh && listRequestsInFlight.has(requestKey)) {
|
||||||
@@ -503,6 +509,15 @@ export const setDepartmentGatewayCutover = async (departmentId, transport_mode)
|
|||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateNumberPlateScanner = async (scannerId, payload = {}) =>
|
||||||
|
authenticatedRequest("/numberplatescanners", "PUT", {
|
||||||
|
id: Number(scannerId),
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rotateNumberPlateScannerKey = async (scannerId) =>
|
||||||
|
authenticatedRequest(`/numberplatescanners/${encodeURIComponent(String(scannerId))}/rotate-key`, "POST", {});
|
||||||
|
|
||||||
export const getEdgeGatewayModuleConfig = async () =>
|
export const getEdgeGatewayModuleConfig = async () =>
|
||||||
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "GET", {}).then((response) => {
|
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "GET", {}).then((response) => {
|
||||||
const entries = normalizeEntries(unwrapEdgeGatewayResponse(response, []));
|
const entries = normalizeEntries(unwrapEdgeGatewayResponse(response, []));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from "vue";
|
|||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||||
|
import EdgeGatewayFleetLanding from "@/features/edgeGateways/EdgeGatewayFleetLanding.vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
import PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
@@ -179,6 +180,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<EdgeGatewayFleetLanding v-if="!moduleState.unavailable && !selectedGatewayId" />
|
||||||
|
|
||||||
<EdgeGatewayManager
|
<EdgeGatewayManager
|
||||||
v-if="!moduleState.unavailable"
|
v-if="!moduleState.unavailable"
|
||||||
:selected-gateway-id="selectedGatewayId"
|
:selected-gateway-id="selectedGatewayId"
|
||||||
|
|||||||
+5
-18
@@ -1,23 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
import EdgeGatewayDepartmentWorkspace from "@/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue";
|
||||||
import PageTitle from "@/components/global/PageTitle.vue";
|
import PageTitle from "@/components/global/PageTitle.vue";
|
||||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const departmentId = computed(() => Number(route.params.departmentId));
|
const departmentId = computed(() => Number(route.params.departmentId));
|
||||||
|
|
||||||
const openGatewayPage = async (gatewayId) => {
|
|
||||||
if (!gatewayId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await router.push(`/superuser/configuration/edgegateway/${encodeURIComponent(String(gatewayId))}/overview`);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,16 +17,11 @@ const openGatewayPage = async (gatewayId) => {
|
|||||||
<DepartmentSubPageWrapper>
|
<DepartmentSubPageWrapper>
|
||||||
<template #title>
|
<template #title>
|
||||||
<PageTitle
|
<PageTitle
|
||||||
title="Gateway for afdelingen"
|
title="Department Hardware Workspace"
|
||||||
subtitle="Overview, tasks, logs, statistics, and inventory without destructive controls"
|
subtitle="Overview, self-serve readiness, gates, scanners, and gateway coverage for this department"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<EdgeGatewayManager
|
<EdgeGatewayDepartmentWorkspace :department-id="departmentId" />
|
||||||
:department-id="departmentId"
|
|
||||||
:route-driven="false"
|
|
||||||
:allow-destructive="false"
|
|
||||||
@open-gateway-page="openGatewayPage"
|
|
||||||
/>
|
|
||||||
</DepartmentSubPageWrapper>
|
</DepartmentSubPageWrapper>
|
||||||
</RestrictedPageWrapper>
|
</RestrictedPageWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
|||||||
test("filters the fleet roster with search and summary chips", async ({ page }) => {
|
test("filters the fleet roster with search and summary chips", async ({ page }) => {
|
||||||
await page.goto("/superuser/configuration/edgegateway");
|
await page.goto("/superuser/configuration/edgegateway");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
|
||||||
await page.getByTestId("gateway-fleet-search").fill("ode");
|
await page.getByTestId("gateway-fleet-search").fill("ode");
|
||||||
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
|
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
|
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
|
||||||
@@ -59,11 +60,29 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
|||||||
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
|
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("opens a department workspace from the fleet landing", async ({ page }) => {
|
||||||
|
await page.goto("/superuser/configuration/edgegateway");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("hardware-fleet-department-1")).toContainText("Copenhagen");
|
||||||
|
|
||||||
|
await page.getByTestId("hardware-fleet-open-workspace-1").click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/superuser\/departments\/1\/gateways(?:\?.*)?$/);
|
||||||
|
await expect(page.getByTestId("department-hardware-workspace")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("department-hardware-primary-gateway")).toContainText("CPH Edge 01");
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps the department workspace on the safe subset and links into the full page", async ({ page }) => {
|
test("keeps the department workspace on the safe subset and links into the full page", async ({ page }) => {
|
||||||
await page.goto("/superuser/departments/1/gateways");
|
await page.goto("/superuser/departments/1/gateways");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("department-hardware-workspace")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("department-hardware-panel-overview")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("department-hardware-tab-gateways")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("department-hardware-tab-gateways").click();
|
||||||
|
|
||||||
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-fleet-item-701")).toBeVisible();
|
|
||||||
await expect(page.getByTestId("gateway-tab-terminal")).toHaveCount(0);
|
await expect(page.getByTestId("gateway-tab-terminal")).toHaveCount(0);
|
||||||
await expect(page.getByTestId("gateway-tab-settings")).toHaveCount(0);
|
await expect(page.getByTestId("gateway-tab-settings")).toHaveCount(0);
|
||||||
await page.getByTestId("gateway-tab-tasks").click();
|
await page.getByTestId("gateway-tab-tasks").click();
|
||||||
|
|||||||
@@ -305,6 +305,46 @@ test.describe("Edge gateway management smoke", () => {
|
|||||||
await expect(page.locator("body")).toContainText("Gateway deleted.");
|
await expect(page.locator("body")).toContainText("Gateway deleted.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("@smoke updates the integrated workspace after binding and scanner assignment changes", async ({ page }) => {
|
||||||
|
await mockApi(page, {
|
||||||
|
authenticated: true,
|
||||||
|
permissions: ["superuser", "user"],
|
||||||
|
});
|
||||||
|
await primeSuperuserSession(page);
|
||||||
|
|
||||||
|
await page.goto("/superuser/departments/1/gateways?tab=lanes");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("department-hardware-panel-lanes")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("department-lane-8")).toContainText("MISSING");
|
||||||
|
|
||||||
|
await page.getByTestId("department-hardware-tab-gateways").click();
|
||||||
|
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("gateway-tab-inventory").click();
|
||||||
|
await page.getByTestId("gateway-binding-add").click();
|
||||||
|
await page.locator('[data-testid^="gateway-binding-relay-"]').last().fill("M-8");
|
||||||
|
await page.locator('[data-testid^="gateway-binding-device-"]').last().selectOption("shelly-plus-01");
|
||||||
|
await page.locator('[data-testid^="gateway-binding-channel-"]').last().selectOption("1");
|
||||||
|
await page.locator('[data-testid^="gateway-binding-fallback-"]').last().selectOption("PREFER_LOCAL");
|
||||||
|
await page.getByTestId("gateway-bindings-save").click();
|
||||||
|
await expect(page.locator("body")).toContainText("Relay bindings saved.");
|
||||||
|
|
||||||
|
await page.getByTestId("department-hardware-tab-lanes").click();
|
||||||
|
await page.getByTestId("department-hardware-refresh").click();
|
||||||
|
await expect(page.getByTestId("department-lane-8")).toContainText("READY");
|
||||||
|
|
||||||
|
await page.getByTestId("department-hardware-tab-scanners").click();
|
||||||
|
await expect(page.getByTestId("department-hardware-panel-scanners")).toBeVisible();
|
||||||
|
await page.getByTestId("department-scanner-lane-2").selectOption("8");
|
||||||
|
await page.getByTestId("department-scanner-save-2").click();
|
||||||
|
await expect(page.getByTestId("department-hardware-notice")).toContainText("lane assignment updated");
|
||||||
|
await expect(page.getByTestId("department-scanner-2")).toContainText("READY");
|
||||||
|
|
||||||
|
await page.getByTestId("department-scanner-rotate-2").click();
|
||||||
|
await expect(page.getByTestId("department-hardware-notice")).toContainText("API key rotated");
|
||||||
|
await expect(page.getByTestId("department-scanner-key-2")).toContainText("rotated-scanner-key-2");
|
||||||
|
});
|
||||||
|
|
||||||
test("@smoke cancels an in-progress gateway task so a replacement task can be queued", async ({ page }) => {
|
test("@smoke cancels an in-progress gateway task so a replacement task can be queued", async ({ page }) => {
|
||||||
await mockApi(page, {
|
await mockApi(page, {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test.describe("Edge gateway visuals", () => {
|
|||||||
await page.goto("/superuser/configuration/edgegateway");
|
await page.goto("/superuser/configuration/edgegateway");
|
||||||
|
|
||||||
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible();
|
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
|
||||||
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
|
await expect(page.getByTestId("gateway-fleet-roster")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
|
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
|
||||||
|
|||||||
@@ -2114,6 +2114,9 @@ export function createPosFixture(overrides = {}) {
|
|||||||
paymentIntentsByOrderId: {},
|
paymentIntentsByOrderId: {},
|
||||||
stripeReadersError: null,
|
stripeReadersError: null,
|
||||||
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
|
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
|
||||||
|
departmentCategoriesDelayMs: 0,
|
||||||
|
productsDelayMs: 0,
|
||||||
|
productsDelayMsByCategory: {},
|
||||||
nextOrderId: 54519,
|
nextOrderId: 54519,
|
||||||
nextOrderItemId: 9200,
|
nextOrderItemId: 9200,
|
||||||
nextAttachmentId: 400,
|
nextAttachmentId: 400,
|
||||||
@@ -2248,6 +2251,20 @@ function createPosStripeModuleOrder(posFixture, orderId, overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFixtureDelayMs(value = 0) {
|
||||||
|
const parsed = Number(value || 0);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeDelayFixtureResponse(delayMs = 0) {
|
||||||
|
const normalizedDelayMs = getFixtureDelayMs(delayMs);
|
||||||
|
if (!normalizedDelayMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, normalizedDelayMs));
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePosRoute({ route, request, parsedUrl, pathname, method, posFixture }) {
|
async function handlePosRoute({ route, request, parsedUrl, pathname, method, posFixture }) {
|
||||||
if (!posFixture) {
|
if (!posFixture) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2259,6 +2276,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.endsWith("/departments/categories") && method === "GET") {
|
if (pathname.endsWith("/departments/categories") && method === "GET") {
|
||||||
|
await maybeDelayFixtureResponse(posFixture.departmentCategoriesDelayMs);
|
||||||
await route.fulfill(json({ success: true, data: posFixture.departmentCategories || [] }));
|
await route.fulfill(json({ success: true, data: posFixture.departmentCategories || [] }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2573,6 +2591,14 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
if (pathname.endsWith("/products") && method === "GET") {
|
if (pathname.endsWith("/products") && method === "GET") {
|
||||||
const productId = Number(parsedUrl.searchParams.get("id") || 0);
|
const productId = Number(parsedUrl.searchParams.get("id") || 0);
|
||||||
const category = Number(parsedUrl.searchParams.get("category") || 0);
|
const category = Number(parsedUrl.searchParams.get("category") || 0);
|
||||||
|
const categoryDelayMap = posFixture.productsDelayMsByCategory || {};
|
||||||
|
const categoryDelayMs =
|
||||||
|
category > 0
|
||||||
|
? categoryDelayMap[category] ?? categoryDelayMap[String(category)] ?? posFixture.productsDelayMs
|
||||||
|
: posFixture.productsDelayMs;
|
||||||
|
|
||||||
|
await maybeDelayFixtureResponse(categoryDelayMs);
|
||||||
|
|
||||||
if (productId > 0) {
|
if (productId > 0) {
|
||||||
await route.fulfill(
|
await route.fulfill(
|
||||||
json({ success: true, data: (posFixture.products || []).find((product) => product.id === productId) || null })
|
json({ success: true, data: (posFixture.products || []).find((product) => product.id === productId) || null })
|
||||||
@@ -2826,8 +2852,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.endsWith("/orders/attachments") && method === "DELETE") {
|
if (pathname.endsWith("/orders/attachments") && method === "DELETE") {
|
||||||
const orderId = Number(parsedUrl.searchParams.get("order_id") || 0);
|
const body = request.postDataJSON?.() || {};
|
||||||
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || 0);
|
const orderId = Number(parsedUrl.searchParams.get("order_id") || body.order_id || 0);
|
||||||
|
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || body.attachment_id || 0);
|
||||||
posFixture.attachmentsByOrderId[orderId] = (posFixture.attachmentsByOrderId[orderId] || []).filter(
|
posFixture.attachmentsByOrderId[orderId] = (posFixture.attachmentsByOrderId[orderId] || []).filter(
|
||||||
(attachment) => attachment.id !== attachmentId
|
(attachment) => attachment.id !== attachmentId
|
||||||
);
|
);
|
||||||
@@ -3142,6 +3169,574 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
|
||||||
|
if (!edgeGatewayFixture) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!edgeGatewayFixture.hardwareWorkspace) {
|
||||||
|
edgeGatewayFixture.hardwareWorkspace = {
|
||||||
|
nextScannerId: 3,
|
||||||
|
departments: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Copenhagen",
|
||||||
|
description: "Primary launch department",
|
||||||
|
order_priority: 1,
|
||||||
|
self_serve_enabled: true,
|
||||||
|
lanes: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
department: 1,
|
||||||
|
name: "Lane 7",
|
||||||
|
status: "AVAILABLE",
|
||||||
|
machine_type_id: 1,
|
||||||
|
relay_in_id: null,
|
||||||
|
relay_out_id: null,
|
||||||
|
relay_machine_id: "M-7",
|
||||||
|
relay_machine_program_picker_id: null,
|
||||||
|
relay_machine_cleaner_id: null,
|
||||||
|
dynamic_image_id: 77,
|
||||||
|
self_serve_products: ["Truck", "Van"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
department: 1,
|
||||||
|
name: "Lane 8",
|
||||||
|
status: "FAULT",
|
||||||
|
machine_type_id: 1,
|
||||||
|
relay_in_id: null,
|
||||||
|
relay_out_id: null,
|
||||||
|
relay_machine_id: "M-8",
|
||||||
|
relay_machine_program_picker_id: null,
|
||||||
|
relay_machine_cleaner_id: null,
|
||||||
|
dynamic_image_id: 78,
|
||||||
|
self_serve_products: ["Truck"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Odense",
|
||||||
|
description: "Fallback transport department",
|
||||||
|
order_priority: 2,
|
||||||
|
self_serve_enabled: true,
|
||||||
|
lanes: [
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
department: 2,
|
||||||
|
name: "Lane 9",
|
||||||
|
status: "AVAILABLE",
|
||||||
|
machine_type_id: 1,
|
||||||
|
relay_in_id: null,
|
||||||
|
relay_out_id: null,
|
||||||
|
relay_machine_id: "M-9",
|
||||||
|
relay_machine_program_picker_id: null,
|
||||||
|
relay_machine_cleaner_id: null,
|
||||||
|
dynamic_image_id: 79,
|
||||||
|
self_serve_products: ["Truck", "Car"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gates: [
|
||||||
|
{
|
||||||
|
id: 41,
|
||||||
|
department: 1,
|
||||||
|
name: "North Entrance",
|
||||||
|
is_entrance: true,
|
||||||
|
is_exit: false,
|
||||||
|
config: {
|
||||||
|
type: "RELAY",
|
||||||
|
relay_id: "M-7",
|
||||||
|
pulse_seconds: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
department: 1,
|
||||||
|
name: "Service Exit",
|
||||||
|
is_entrance: false,
|
||||||
|
is_exit: true,
|
||||||
|
config: {
|
||||||
|
type: "PHONE_CALL",
|
||||||
|
phone_number: "+4512345678",
|
||||||
|
call_duration_threshold: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 43,
|
||||||
|
department: 2,
|
||||||
|
name: "Odense Main Gate",
|
||||||
|
is_entrance: true,
|
||||||
|
is_exit: false,
|
||||||
|
config: {
|
||||||
|
type: "PHONE_CALL",
|
||||||
|
phone_number: "+4598765432",
|
||||||
|
call_duration_threshold: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scanners: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
department_id: 1,
|
||||||
|
name: "North scanner",
|
||||||
|
notes: "Mounted at the primary entry lane",
|
||||||
|
lane_id: 7,
|
||||||
|
api_key: "scanner-key-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
department_id: 1,
|
||||||
|
name: "South scanner",
|
||||||
|
notes: "Waiting for lane assignment",
|
||||||
|
lane_id: null,
|
||||||
|
api_key: "scanner-key-2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scans: [
|
||||||
|
{
|
||||||
|
id: 801,
|
||||||
|
department_id: 1,
|
||||||
|
plate_scanner_id: 1,
|
||||||
|
plate: "AB12345",
|
||||||
|
bay_id: "7",
|
||||||
|
created_at: "2026-04-08 08:44:07",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 802,
|
||||||
|
department_id: 1,
|
||||||
|
plate_scanner_id: 2,
|
||||||
|
plate: "CD67890",
|
||||||
|
bay_id: "8",
|
||||||
|
created_at: "2026-04-08 08:31:05",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgeGatewayFixture.hardwareWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEdgeGatewayHardwareBindingsIndex(edgeGatewayFixture, departmentId) {
|
||||||
|
const gateways = edgeGatewayFixture.gateways.filter(
|
||||||
|
(gateway) => Number(gateway.department_id) === Number(departmentId)
|
||||||
|
);
|
||||||
|
const bindingsByRelayId = {};
|
||||||
|
|
||||||
|
gateways.forEach((gateway) => {
|
||||||
|
(gateway.bindings || []).forEach((binding) => {
|
||||||
|
const relayId = String(binding?.relay_id || "").trim();
|
||||||
|
if (!relayId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bindingsByRelayId[relayId]) {
|
||||||
|
bindingsByRelayId[relayId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
bindingsByRelayId[relayId].push({
|
||||||
|
...cloneJson(binding),
|
||||||
|
gateway_id: gateway.id,
|
||||||
|
gateway_label: gateway.label || `Gateway ${gateway.id}`,
|
||||||
|
gateway_status: gateway.status || "OFFLINE",
|
||||||
|
is_primary_gateway: Boolean(gateway.is_primary),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.values(bindingsByRelayId).forEach((bindings) => {
|
||||||
|
bindings.sort((left, right) => {
|
||||||
|
if (Number(Boolean(right.is_primary_gateway)) !== Number(Boolean(left.is_primary_gateway))) {
|
||||||
|
return Number(Boolean(right.is_primary_gateway)) - Number(Boolean(left.is_primary_gateway));
|
||||||
|
}
|
||||||
|
return Number(left.gateway_id || 0) - Number(right.gateway_id || 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
gateways,
|
||||||
|
bindingsByRelayId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) {
|
||||||
|
const bindings = bindingsByRelayId[String(relayId || "").trim()] || [];
|
||||||
|
return {
|
||||||
|
relay_id: relayId,
|
||||||
|
covered: bindings.length > 0,
|
||||||
|
status: bindings.length > 0 ? "BOUND" : "MISSING",
|
||||||
|
binding_count: bindings.length,
|
||||||
|
primary_binding: bindings[0] || null,
|
||||||
|
bindings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, includeGateways = true) {
|
||||||
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
|
const department = (hardware?.departments || []).find((entry) => Number(entry.id) === Number(departmentId)) || null;
|
||||||
|
if (!department) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gateways, bindingsByRelayId } = buildEdgeGatewayHardwareBindingsIndex(edgeGatewayFixture, departmentId);
|
||||||
|
const gatewayPayloads = gateways.map((gateway) =>
|
||||||
|
buildHttpEdgeGatewayGateway(edgeGatewayFixture, settleEdgeGatewayWork(edgeGatewayFixture, gateway.id), true)
|
||||||
|
);
|
||||||
|
|
||||||
|
const lanes = (department.lanes || []).map((lane) => {
|
||||||
|
const relaySlots = [
|
||||||
|
["ENTRY", lane.relay_in_id],
|
||||||
|
["EXIT", lane.relay_out_id],
|
||||||
|
["MACHINE", lane.relay_machine_id],
|
||||||
|
["PROGRAM_PICKER", lane.relay_machine_program_picker_id],
|
||||||
|
["CLEANER", lane.relay_machine_cleaner_id],
|
||||||
|
]
|
||||||
|
.filter(([, relayId]) => Boolean(relayId))
|
||||||
|
.map(([slot, relayId]) => ({
|
||||||
|
slot,
|
||||||
|
relay_id: relayId,
|
||||||
|
catalog: relayId ? { relay_id: relayId } : null,
|
||||||
|
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requiredRelayCount = relaySlots.length;
|
||||||
|
const boundRelayCount = relaySlots.filter((slot) => slot.coverage.covered).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: lane.id,
|
||||||
|
department: lane.department,
|
||||||
|
name: lane.name,
|
||||||
|
relay_in_id: lane.relay_in_id,
|
||||||
|
relay_out_id: lane.relay_out_id,
|
||||||
|
relay_machine_id: lane.relay_machine_id,
|
||||||
|
relay_machine_program_picker_id: lane.relay_machine_program_picker_id,
|
||||||
|
relay_machine_cleaner_id: lane.relay_machine_cleaner_id,
|
||||||
|
dynamic_image_id: lane.dynamic_image_id,
|
||||||
|
machine_type_id: lane.machine_type_id,
|
||||||
|
status: lane.status,
|
||||||
|
self_serve_products: cloneJson(lane.self_serve_products || []),
|
||||||
|
relay_slots: relaySlots,
|
||||||
|
binding_coverage: {
|
||||||
|
required: requiredRelayCount,
|
||||||
|
bound: boundRelayCount,
|
||||||
|
missing: Math.max(0, requiredRelayCount - boundRelayCount),
|
||||||
|
state: requiredRelayCount === 0 ? "NOT_REQUIRED" : boundRelayCount === requiredRelayCount ? "READY" : "MISSING",
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
legacy: `/superuser/department/lanes/${lane.id}`,
|
||||||
|
self_serve_studio: `/admin/${department.id}/modules/self-serve/studio`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const selfServe = {
|
||||||
|
enabled: Boolean(department.self_serve_enabled),
|
||||||
|
lane_count: lanes.length,
|
||||||
|
ready_lanes: lanes.filter((lane) => String(lane.binding_coverage?.state || "") === "READY").length,
|
||||||
|
configured_task_count: lanes.reduce((sum, lane) => sum + (lane.self_serve_products?.length || 0), 0),
|
||||||
|
configured_product_count: new Set(lanes.flatMap((lane) => lane.self_serve_products || [])).size,
|
||||||
|
readiness_state: !department.self_serve_enabled
|
||||||
|
? "DISABLED"
|
||||||
|
: lanes.length === 0
|
||||||
|
? "UNCONFIGURED"
|
||||||
|
: lanes.every((lane) => String(lane.binding_coverage?.state || "") === "READY")
|
||||||
|
? "READY"
|
||||||
|
: "PARTIAL",
|
||||||
|
links: {
|
||||||
|
studio: `/admin/${department.id}/modules/self-serve/studio`,
|
||||||
|
legacy: "/superuser/selfserve",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const gates = (hardware.gates || [])
|
||||||
|
.filter((gate) => Number(gate.department) === Number(departmentId))
|
||||||
|
.map((gate) => {
|
||||||
|
const transportType = String(gate?.config?.type || "PHONE_CALL").toUpperCase();
|
||||||
|
const relayId = String(gate?.config?.relay_id || "").trim();
|
||||||
|
const coverage =
|
||||||
|
transportType === "RELAY" && relayId ? buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: gate.id,
|
||||||
|
department: gate.department,
|
||||||
|
name: gate.name,
|
||||||
|
is_entrance: Boolean(gate.is_entrance),
|
||||||
|
is_exit: Boolean(gate.is_exit),
|
||||||
|
config: cloneJson(gate.config || {}),
|
||||||
|
transport_type: transportType,
|
||||||
|
config_complete:
|
||||||
|
transportType === "PHONE_CALL"
|
||||||
|
? Boolean(gate?.config?.phone_number) && Number(gate?.config?.call_duration_threshold || 0) > 0
|
||||||
|
: Boolean(relayId),
|
||||||
|
relay: relayId ? { relay_id: relayId } : null,
|
||||||
|
coverage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const laneIndex = Object.fromEntries(lanes.map((lane) => [Number(lane.id), lane]));
|
||||||
|
const scansByScannerId = {};
|
||||||
|
(hardware.scans || [])
|
||||||
|
.filter((scan) => Number(scan.department_id) === Number(departmentId))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(String(right.created_at || 0)).getTime() - new Date(String(left.created_at || 0)).getTime()
|
||||||
|
)
|
||||||
|
.forEach((scan) => {
|
||||||
|
const scannerId = Number(scan.plate_scanner_id || 0);
|
||||||
|
if (!scansByScannerId[scannerId]) {
|
||||||
|
scansByScannerId[scannerId] = [];
|
||||||
|
}
|
||||||
|
if (scansByScannerId[scannerId].length >= 5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scansByScannerId[scannerId].push(cloneJson(scan));
|
||||||
|
});
|
||||||
|
|
||||||
|
const scanners = (hardware.scanners || [])
|
||||||
|
.filter((scanner) => Number(scanner.department_id) === Number(departmentId))
|
||||||
|
.map((scanner) => {
|
||||||
|
const assignedLane = scanner.lane_id ? laneIndex[Number(scanner.lane_id)] || null : null;
|
||||||
|
const assignmentState = !scanner.lane_id
|
||||||
|
? "UNASSIGNED"
|
||||||
|
: !assignedLane
|
||||||
|
? "INVALID"
|
||||||
|
: Number(assignedLane?.binding_coverage?.missing || 0) === 0
|
||||||
|
? "READY"
|
||||||
|
: "PARTIAL";
|
||||||
|
const recentScans = scansByScannerId[Number(scanner.id)] || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cloneJson(scanner),
|
||||||
|
assigned_lane: assignedLane,
|
||||||
|
assignment_state: assignmentState,
|
||||||
|
recent_scan_at: recentScans[0]?.created_at || null,
|
||||||
|
recent_scans: recentScans,
|
||||||
|
recent_scan_count: recentScans.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
const onlineGatewayCount = gatewayPayloads.filter(
|
||||||
|
(gateway) => String(gateway?.status || "").toUpperCase() === "ONLINE"
|
||||||
|
).length;
|
||||||
|
const transportMode = String(
|
||||||
|
gatewayPayloads[0]?.department_transport_mode || gateways[0]?.department_transport_mode || "cloud"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (gatewayPayloads.length === 0) {
|
||||||
|
issues.push({
|
||||||
|
severity: "danger",
|
||||||
|
code: "NO_GATEWAY",
|
||||||
|
message: "No edge gateway has been claimed for this department.",
|
||||||
|
});
|
||||||
|
} else if (transportMode === "gateway" && onlineGatewayCount === 0) {
|
||||||
|
issues.push({
|
||||||
|
severity: "danger",
|
||||||
|
code: "NO_ONLINE_GATEWAY",
|
||||||
|
message: "Gateway transport mode is enabled, but no department gateway is currently online.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lanes.forEach((lane) => {
|
||||||
|
if (Number(lane.binding_coverage?.missing || 0) > 0) {
|
||||||
|
issues.push({
|
||||||
|
severity: "warning",
|
||||||
|
code: "LANE_BINDING_GAP",
|
||||||
|
message: `Lane ${lane.name} is missing relay bindings.`,
|
||||||
|
target_type: "lane",
|
||||||
|
target_id: lane.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gates.forEach((gate) => {
|
||||||
|
if (!gate.config_complete) {
|
||||||
|
issues.push({
|
||||||
|
severity: "warning",
|
||||||
|
code: "GATE_CONFIG_INCOMPLETE",
|
||||||
|
message: `Gate ${gate.name} has incomplete transport configuration.`,
|
||||||
|
target_type: "gate",
|
||||||
|
target_id: gate.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gate.transport_type === "RELAY" && !gate.coverage?.covered) {
|
||||||
|
issues.push({
|
||||||
|
severity: "warning",
|
||||||
|
code: "GATE_BINDING_MISSING",
|
||||||
|
message: `Gate ${gate.name} is assigned to an unbound relay.`,
|
||||||
|
target_type: "gate",
|
||||||
|
target_id: gate.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scanners.forEach((scanner) => {
|
||||||
|
if (scanner.assignment_state === "UNASSIGNED") {
|
||||||
|
issues.push({
|
||||||
|
severity: "warning",
|
||||||
|
code: "SCANNER_UNASSIGNED",
|
||||||
|
message: `Scanner ${scanner.name} is not assigned to a default lane.`,
|
||||||
|
target_type: "scanner",
|
||||||
|
target_id: scanner.id,
|
||||||
|
});
|
||||||
|
} else if (scanner.assignment_state === "PARTIAL") {
|
||||||
|
issues.push({
|
||||||
|
severity: "info",
|
||||||
|
code: "SCANNER_LANE_PARTIAL",
|
||||||
|
message: `Scanner ${scanner.name} is assigned to a lane with missing relay coverage.`,
|
||||||
|
target_type: "scanner",
|
||||||
|
target_id: scanner.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selfServe.enabled && Number(selfServe.ready_lanes || 0) < Number(selfServe.lane_count || 0)) {
|
||||||
|
issues.push({
|
||||||
|
severity: "warning",
|
||||||
|
code: "SELFSERVE_PARTIAL_READY",
|
||||||
|
message: "Self-serve is enabled, but one or more lanes are missing required relay coverage.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
code: "OPEN_GATEWAY_TAB",
|
||||||
|
label: "Open gateway controls",
|
||||||
|
path: `/superuser/departments/${departmentId}/gateways?tab=gateways`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (gatewayPayloads.length === 0) {
|
||||||
|
actions.push({
|
||||||
|
code: "INSTALL_GATEWAY",
|
||||||
|
label: "Install first edge gateway",
|
||||||
|
path: "/superuser/configuration/edgegateway",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lanes.some((lane) => Number(lane.binding_coverage?.missing || 0) > 0)) {
|
||||||
|
actions.push({
|
||||||
|
code: "REVIEW_LANE_BINDINGS",
|
||||||
|
label: "Resolve lane bindings",
|
||||||
|
path: `/superuser/departments/${departmentId}/gateways?tab=lanes`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gates.some((gate) => gate.transport_type === "RELAY" && !gate.coverage?.covered)) {
|
||||||
|
actions.push({
|
||||||
|
code: "REVIEW_GATE_BINDINGS",
|
||||||
|
label: "Resolve gate relay bindings",
|
||||||
|
path: `/superuser/departments/${departmentId}/gateways?tab=gates`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanners.some((scanner) => scanner.assignment_state === "UNASSIGNED")) {
|
||||||
|
actions.push({
|
||||||
|
code: "ASSIGN_SCANNERS",
|
||||||
|
label: "Assign scanners to lanes",
|
||||||
|
path: `/superuser/departments/${departmentId}/gateways?tab=scanners`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selfServe.enabled && lanes.length > 0) {
|
||||||
|
actions.push({
|
||||||
|
code: "OPEN_SELFSERVE_STUDIO",
|
||||||
|
label: "Open self-serve studio",
|
||||||
|
path: `/admin/${departmentId}/modules/self-serve/studio`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredRelayIds = new Set();
|
||||||
|
const coveredRelayIds = new Set();
|
||||||
|
lanes.forEach((lane) => {
|
||||||
|
(lane.relay_slots || []).forEach((slot) => {
|
||||||
|
if (!slot?.relay_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requiredRelayIds.add(slot.relay_id);
|
||||||
|
if (slot.coverage?.covered) {
|
||||||
|
coveredRelayIds.add(slot.relay_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
gates.forEach((gate) => {
|
||||||
|
const relayId = String(gate?.relay?.relay_id || gate?.config?.relay_id || "").trim();
|
||||||
|
if (!relayId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requiredRelayIds.add(relayId);
|
||||||
|
if (gate.coverage?.covered) {
|
||||||
|
coveredRelayIds.add(relayId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const primaryGateway = gatewayPayloads.find((gateway) => gateway?.is_primary) || gatewayPayloads[0] || null;
|
||||||
|
const recentScanAt =
|
||||||
|
scanners
|
||||||
|
.map((scanner) => scanner?.recent_scan_at)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => new Date(String(right || 0)).getTime() - new Date(String(left || 0)).getTime())[0] || null;
|
||||||
|
|
||||||
|
const health = issues.some((issue) => issue.severity === "danger")
|
||||||
|
? "AT_RISK"
|
||||||
|
: issues.length > 0
|
||||||
|
? "PARTIAL"
|
||||||
|
: "READY";
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
department_id: department.id,
|
||||||
|
department_name: department.name,
|
||||||
|
order_priority: department.order_priority,
|
||||||
|
transport_mode: transportMode,
|
||||||
|
gateway_count: gatewayPayloads.length,
|
||||||
|
online_gateway_count: onlineGatewayCount,
|
||||||
|
primary_gateway: primaryGateway
|
||||||
|
? {
|
||||||
|
id: primaryGateway.id,
|
||||||
|
label: primaryGateway.label || `Gateway ${primaryGateway.id}`,
|
||||||
|
status: primaryGateway.status || "OFFLINE",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lane_count: lanes.length,
|
||||||
|
self_serve_enabled: selfServe.enabled,
|
||||||
|
self_serve_ready_lanes: selfServe.ready_lanes,
|
||||||
|
required_relay_count: requiredRelayIds.size,
|
||||||
|
bound_relay_count: coveredRelayIds.size,
|
||||||
|
missing_binding_count: Math.max(0, requiredRelayIds.size - coveredRelayIds.size),
|
||||||
|
gate_count: gates.length,
|
||||||
|
gate_transport_mix: {
|
||||||
|
relay: gates.filter((gate) => gate.transport_type === "RELAY").length,
|
||||||
|
phone_call: gates.filter((gate) => gate.transport_type === "PHONE_CALL").length,
|
||||||
|
},
|
||||||
|
scanner_count: scanners.length,
|
||||||
|
assigned_scanner_count: scanners.filter((scanner) => Number(scanner.lane_id || 0) > 0).length,
|
||||||
|
recent_scan_at: recentScanAt,
|
||||||
|
issue_count: issues.length,
|
||||||
|
health,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
department: {
|
||||||
|
id: department.id,
|
||||||
|
name: department.name,
|
||||||
|
description: department.description,
|
||||||
|
order_priority: department.order_priority,
|
||||||
|
},
|
||||||
|
summary,
|
||||||
|
gateways: includeGateways ? gatewayPayloads : [],
|
||||||
|
lanes,
|
||||||
|
self_serve: selfServe,
|
||||||
|
gates,
|
||||||
|
scanners,
|
||||||
|
issues,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, method, edgeGatewayFixture }) {
|
async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, method, edgeGatewayFixture }) {
|
||||||
if (!edgeGatewayFixture) {
|
if (!edgeGatewayFixture) {
|
||||||
return false;
|
return false;
|
||||||
@@ -3167,6 +3762,8 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
|||||||
return operation;
|
return operation;
|
||||||
};
|
};
|
||||||
const edgeGatewayCollectionPattern = /\/(?:modules\/)?edge-gateways$/;
|
const edgeGatewayCollectionPattern = /\/(?:modules\/)?edge-gateways$/;
|
||||||
|
const edgeGatewayWorkspaceDepartmentsPattern = /\/modules\/edge-gateways\/workspace\/departments$/;
|
||||||
|
const edgeGatewayWorkspaceDepartmentPattern = /\/modules\/edge-gateways\/workspace\/departments\/(\d+)$/;
|
||||||
const edgeGatewayDetailPattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
const edgeGatewayDetailPattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
||||||
const edgeGatewayTasksPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/tasks$/;
|
const edgeGatewayTasksPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/tasks$/;
|
||||||
const edgeGatewayLogsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/logs$/;
|
const edgeGatewayLogsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/logs$/;
|
||||||
@@ -3180,11 +3777,13 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
|||||||
const edgeGatewayInstallTokenPattern = /\/(?:modules\/)?edge-gateways\/install-token$/;
|
const edgeGatewayInstallTokenPattern = /\/(?:modules\/)?edge-gateways\/install-token$/;
|
||||||
const edgeGatewayInstallTokenStatusPattern = /\/(?:modules\/)?edge-gateways\/install-token\/(\d+)\/status$/;
|
const edgeGatewayInstallTokenStatusPattern = /\/(?:modules\/)?edge-gateways\/install-token\/(\d+)\/status$/;
|
||||||
const edgeGatewayBindingsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/bindings$/;
|
const edgeGatewayBindingsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/bindings$/;
|
||||||
|
const numberPlateScannersPattern = /\/numberplatescanners$/;
|
||||||
|
const numberPlateScannerRotatePattern = /\/numberplatescanners\/(\d+)\/rotate-key$/;
|
||||||
const edgeGatewayDeletePattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
const edgeGatewayDeletePattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
||||||
const edgeGatewayCutoverPattern =
|
const edgeGatewayCutoverPattern =
|
||||||
/\/(?:modules\/edge-gateways\/departments\/(\d+)\/cutover|departments\/(\d+)\/gateway-cutover)$/;
|
/\/(?:modules\/edge-gateways\/departments\/(\d+)\/cutover|departments\/(\d+)\/gateway-cutover)$/;
|
||||||
const edgeGatewayUnsupportedPattern = /\/(?:modules\/)?edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/;
|
const edgeGatewayUnsupportedPattern = /\/(?:modules\/)?edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/;
|
||||||
const edgeGatewayShellPattern = /\/(?:modules\/)?edge-gateways\/\d+\/shell-sessions(?:\/.*)?$/;
|
const edgeGatewayShellPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/shell-sessions(?:\/.*)?$/;
|
||||||
const buildConfigEntries = () => [
|
const buildConfigEntries = () => [
|
||||||
{ variable: "enabled", value: edgeGatewayFixture.config.enabled },
|
{ variable: "enabled", value: edgeGatewayFixture.config.enabled },
|
||||||
{ variable: "default_release_channel", value: edgeGatewayFixture.config.default_release_channel },
|
{ variable: "default_release_channel", value: edgeGatewayFixture.config.default_release_channel },
|
||||||
@@ -3230,6 +3829,108 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (edgeGatewayWorkspaceDepartmentsPattern.test(pathname) && method === "GET") {
|
||||||
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
|
const summaries = (hardware?.departments || [])
|
||||||
|
.map(
|
||||||
|
(department) => buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, department.id, false)?.summary
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => Number(left?.order_priority || 0) - Number(right?.order_priority || 0));
|
||||||
|
await route.fulfill(json({ data: summaries }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edgeGatewayWorkspaceDepartmentPattern.test(pathname) && method === "GET") {
|
||||||
|
const departmentId = extractMatchId(edgeGatewayWorkspaceDepartmentPattern);
|
||||||
|
const payload = buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, true);
|
||||||
|
await route.fulfill(payload ? json({ data: payload }) : json({ message: "Department not found" }, 404));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberPlateScannersPattern.test(pathname) && method === "GET") {
|
||||||
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
|
const rows = cloneJson(hardware?.scanners || []);
|
||||||
|
const paginated = paginateRows(
|
||||||
|
rows,
|
||||||
|
parsedUrl.searchParams.get("page") || 1,
|
||||||
|
parsedUrl.searchParams.get("limit") || 10
|
||||||
|
);
|
||||||
|
await route.fulfill(
|
||||||
|
json({
|
||||||
|
success: true,
|
||||||
|
data: paginated.rows,
|
||||||
|
meta: paginated.meta,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberPlateScannersPattern.test(pathname) && method === "POST") {
|
||||||
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
|
const body = request.postDataJSON?.() || {};
|
||||||
|
const scanner = {
|
||||||
|
id: hardware.nextScannerId++,
|
||||||
|
department_id: Number(body.department_id || 0),
|
||||||
|
name: String(body.name || ""),
|
||||||
|
notes: String(body.notes || ""),
|
||||||
|
lane_id: body.lane_id === null || body.lane_id === undefined || body.lane_id === "" ? null : Number(body.lane_id),
|
||||||
|
api_key: `scanner-key-${Date.now()}`,
|
||||||
|
};
|
||||||
|
hardware.scanners.push(scanner);
|
||||||
|
await route.fulfill(json({ success: true, data: cloneJson(scanner) }, 201));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberPlateScannersPattern.test(pathname) && method === "PUT") {
|
||||||
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
|
const body = request.postDataJSON?.() || {};
|
||||||
|
const scannerId = Number(body.id || 0);
|
||||||
|
const scannerIndex = (hardware.scanners || []).findIndex((scanner) => Number(scanner.id) === scannerId);
|
||||||
|
if (scannerIndex === -1) {
|
||||||
|
await route.fulfill(json({ message: "Scanner not found" }, 404));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hardware.scanners[scannerIndex] = {
|
||||||
|
...hardware.scanners[scannerIndex],
|
||||||
|
department_id: Number(body.department_id || hardware.scanners[scannerIndex].department_id || 0),
|
||||||
|
name: body.name === undefined ? hardware.scanners[scannerIndex].name : String(body.name || ""),
|
||||||
|
notes: body.notes === undefined ? hardware.scanners[scannerIndex].notes : String(body.notes || ""),
|
||||||
|
lane_id:
|
||||||
|
body.lane_id === undefined
|
||||||
|
? hardware.scanners[scannerIndex].lane_id
|
||||||
|
: body.lane_id === null || body.lane_id === ""
|
||||||
|
? null
|
||||||
|
: Number(body.lane_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.fulfill(json({ success: true, data: cloneJson(hardware.scanners[scannerIndex]) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberPlateScannerRotatePattern.test(pathname) && method === "POST") {
|
||||||
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
||||||
|
const scannerId = extractMatchId(numberPlateScannerRotatePattern);
|
||||||
|
const scanner = (hardware.scanners || []).find((entry) => Number(entry.id) === scannerId) || null;
|
||||||
|
if (!scanner) {
|
||||||
|
await route.fulfill(json({ message: "Scanner not found" }, 404));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner.api_key = `rotated-scanner-key-${scanner.id}`;
|
||||||
|
await route.fulfill(
|
||||||
|
json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
scanner: cloneJson(scanner),
|
||||||
|
api_key: scanner.api_key,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (edgeGatewayCollectionPattern.test(pathname) && method === "GET") {
|
if (edgeGatewayCollectionPattern.test(pathname) && method === "GET") {
|
||||||
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
||||||
const departmentId = Number(parsedUrl.searchParams.get("department_id") || 0);
|
const departmentId = Number(parsedUrl.searchParams.get("department_id") || 0);
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
|
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
|
||||||
|
getEdgeGatewayDepartmentWorkspace,
|
||||||
getEdgeGatewayInstallTokenStatus,
|
getEdgeGatewayInstallTokenStatus,
|
||||||
getEdgeGatewayModuleConfig,
|
getEdgeGatewayModuleConfig,
|
||||||
|
listEdgeGatewayDepartmentWorkspaces,
|
||||||
|
rotateNumberPlateScannerKey,
|
||||||
setEdgeGatewayModuleConfig,
|
setEdgeGatewayModuleConfig,
|
||||||
|
updateNumberPlateScanner,
|
||||||
} from "@/services/edgeGateways.js";
|
} from "@/services/edgeGateways.js";
|
||||||
|
|
||||||
describe("edge gateway service", () => {
|
describe("edge gateway service", () => {
|
||||||
@@ -76,6 +80,69 @@ describe("edge gateway service", () => {
|
|||||||
expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways/install-token/9001/status", "GET", {});
|
expect(authenticatedRequestMock).toHaveBeenCalledWith("/edge-gateways/install-token/9001/status", "GET", {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads department workspace summaries through the module workspace endpoint", async () => {
|
||||||
|
authenticatedRequestMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await listEdgeGatewayDepartmentWorkspaces();
|
||||||
|
|
||||||
|
expect(authenticatedRequestMock).toHaveBeenCalledWith("/modules/edge-gateways/workspace/departments", "GET", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads one department hardware workspace through the detail endpoint", async () => {
|
||||||
|
authenticatedRequestMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
department: { id: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await getEdgeGatewayDepartmentWorkspace(1);
|
||||||
|
|
||||||
|
expect(authenticatedRequestMock).toHaveBeenCalledWith("/modules/edge-gateways/workspace/departments/1", "GET", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates scanner lane assignments through the shared scanner endpoint", async () => {
|
||||||
|
authenticatedRequestMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
id: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateNumberPlateScanner(8, {
|
||||||
|
department_id: 1,
|
||||||
|
lane_id: 7,
|
||||||
|
name: "North scanner",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authenticatedRequestMock).toHaveBeenCalledWith("/numberplatescanners", "PUT", {
|
||||||
|
id: 8,
|
||||||
|
department_id: 1,
|
||||||
|
lane_id: 7,
|
||||||
|
name: "North scanner",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rotates scanner API keys through the dedicated rotate action", async () => {
|
||||||
|
authenticatedRequestMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
api_key: "rotated-key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await rotateNumberPlateScannerKey(8);
|
||||||
|
|
||||||
|
expect(authenticatedRequestMock).toHaveBeenCalledWith("/numberplatescanners/8/rotate-key", "POST", {});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a stable browser cache namespace for workspace snapshots", () => {
|
it("keeps a stable browser cache namespace for workspace snapshots", () => {
|
||||||
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
|
const source = readFileSync(join(process.cwd(), "src/services/edgeGateways.js"), "utf8");
|
||||||
|
|
||||||
|
|||||||
@@ -6,36 +6,23 @@ import { inferEdgeGatewayErrorCode, normalizeEdgeGatewayError } from "@/features
|
|||||||
|
|
||||||
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
||||||
const tasksSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
|
const tasksSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
|
||||||
const settingsSource = readFileSync(
|
const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8");
|
||||||
join(process.cwd(), "src/features/edgeGateways/EdgeGatewaySettingsPage.vue"),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
const terminalSource = readFileSync(
|
|
||||||
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTerminalPage.vue"),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("edge gateway workflow helpers", () => {
|
describe("edge gateway workflow helpers", () => {
|
||||||
it("exposes routed v2 tabs, workflow actions, and shell controls", () => {
|
it("exposes the expanded gateway workspace tabs and management actions", () => {
|
||||||
expect(managerSource).toContain('id: "overview"');
|
expect(managerSource).toContain('id: "overview"');
|
||||||
|
expect(managerSource).toContain('id: "inventory"');
|
||||||
expect(managerSource).toContain('id: "tasks"');
|
expect(managerSource).toContain('id: "tasks"');
|
||||||
expect(managerSource).toContain('id: "logs"');
|
expect(managerSource).toContain('id: "logs"');
|
||||||
expect(managerSource).toContain('id: "statistics"');
|
expect(managerSource).toContain('id: "statistics"');
|
||||||
expect(managerSource).toContain('id: "terminal"');
|
expect(managerSource).toContain('id: "terminal"');
|
||||||
expect(managerSource).toContain('id: "inventory"');
|
|
||||||
expect(managerSource).toContain('id: "settings"');
|
expect(managerSource).toContain('id: "settings"');
|
||||||
expect(managerSource).toContain("`gateway-tab-${tab.id}`");
|
expect(managerSource).toContain("`gateway-tab-${tab.id}`");
|
||||||
expect(managerSource).toContain('if (nextView === "operations")');
|
|
||||||
expect(managerSource).toContain('return "tasks"');
|
|
||||||
expect(managerSource).toContain('if (nextView === "manage")');
|
|
||||||
expect(managerSource).toContain('return "settings"');
|
|
||||||
expect(managerSource).toContain("EdgeGatewayTasksPage");
|
|
||||||
expect(managerSource).toContain("EdgeGatewayTerminalPage");
|
|
||||||
expect(tasksSource).toContain('data-testid="gateway-operation-update"');
|
expect(tasksSource).toContain('data-testid="gateway-operation-update"');
|
||||||
expect(tasksSource).toContain('data-testid="gateway-operation-uninstall"');
|
expect(tasksSource).toContain('data-testid="gateway-operation-uninstall"');
|
||||||
expect(settingsSource).toContain('data-testid="gateway-rotate-credentials"');
|
expect(manageSource).toContain('data-testid="gateway-rotate-credentials"');
|
||||||
expect(terminalSource).toContain('data-testid="gateway-terminal-connect"');
|
expect(managerSource).toContain("EdgeGatewayTerminalPage");
|
||||||
expect(terminalSource).toContain('data-testid="gateway-terminal-send"');
|
expect(managerSource).toContain("createGatewayShellClient");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps structured edge gateway errors into UI copy", () => {
|
it("maps structured edge gateway errors into UI copy", () => {
|
||||||
|
|||||||
@@ -2,35 +2,57 @@ import { readFileSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const root = process.cwd();
|
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
||||||
const managerSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
const overviewSource = readFileSync(
|
||||||
const overviewSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"), "utf8");
|
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"),
|
||||||
const inventorySource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"), "utf8");
|
"utf8"
|
||||||
const tasksSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
|
);
|
||||||
const logsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayLogsPage.vue"), "utf8");
|
const inventorySource = readFileSync(
|
||||||
const statisticsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayStatisticsPage.vue"), "utf8");
|
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"),
|
||||||
const terminalSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayTerminalPage.vue"), "utf8");
|
"utf8"
|
||||||
const settingsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewaySettingsPage.vue"), "utf8");
|
);
|
||||||
const routerSource = readFileSync(join(root, "src/router.js"), "utf8");
|
const tasksSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
|
||||||
|
const logsSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayLogsPage.vue"), "utf8");
|
||||||
|
const statisticsSource = readFileSync(
|
||||||
|
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayStatisticsPage.vue"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const terminalSource = readFileSync(
|
||||||
|
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTerminalPage.vue"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8");
|
||||||
|
const routerSource = readFileSync(join(process.cwd(), "src/router.js"), "utf8");
|
||||||
const edgeGatewaysPageSource = readFileSync(
|
const edgeGatewaysPageSource = readFileSync(
|
||||||
join(root, "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
|
join(process.cwd(), "src/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const departmentWorkspaceSource = readFileSync(
|
||||||
|
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayDepartmentWorkspace.vue"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const fleetLandingSource = readFileSync(
|
||||||
|
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayFleetLanding.vue"),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
const departmentGatewaysPageSource = readFileSync(
|
const departmentGatewaysPageSource = readFileSync(
|
||||||
join(root, "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
|
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
const navigationSource = readFileSync(
|
const navigationSource = readFileSync(
|
||||||
join(root, "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"),
|
join(process.cwd(), "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
const configurationNavigationSource = readFileSync(
|
const configurationNavigationSource = readFileSync(
|
||||||
join(root, "src/views/dashboards/superUserDashboard/configuration/SuperUserDashboardConfigurationNavigation.vue"),
|
join(
|
||||||
|
process.cwd(),
|
||||||
|
"src/views/dashboards/superUserDashboard/configuration/SuperUserDashboardConfigurationNavigation.vue"
|
||||||
|
),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("edge gateway workspace contract", () => {
|
describe("edge gateway workspace contract", () => {
|
||||||
it("splits the module UI into route-aligned overview, tasks, logs, statistics, terminal, inventory, and settings pages", () => {
|
it("splits the module UI into overview, inventory, tasks, logs, statistics, terminal, and settings modules", () => {
|
||||||
expect(managerSource).toContain('data-testid="edge-gateway-workspace"');
|
expect(managerSource).toContain('data-testid="edge-gateway-workspace"');
|
||||||
expect(managerSource).toContain('data-testid="gateway-summary-strip"');
|
expect(managerSource).toContain('data-testid="gateway-summary-strip"');
|
||||||
expect(managerSource).toContain('data-testid="gateway-fleet-usage"');
|
expect(managerSource).toContain('data-testid="gateway-fleet-usage"');
|
||||||
@@ -38,22 +60,14 @@ describe("edge gateway workspace contract", () => {
|
|||||||
expect(managerSource).toContain('data-testid="gateway-installer-status"');
|
expect(managerSource).toContain('data-testid="gateway-installer-status"');
|
||||||
expect(managerSource).toContain('data-testid="gateway-installer-status-step"');
|
expect(managerSource).toContain('data-testid="gateway-installer-status-step"');
|
||||||
expect(managerSource).toContain('data-testid="gateway-installer-status-state"');
|
expect(managerSource).toContain('data-testid="gateway-installer-status-state"');
|
||||||
expect(managerSource).toContain('data-testid="gateway-installer-copy-diagnostics"');
|
|
||||||
expect(managerSource).toContain('id: "tasks"');
|
|
||||||
expect(managerSource).toContain('id: "logs"');
|
|
||||||
expect(managerSource).toContain('id: "statistics"');
|
|
||||||
expect(managerSource).toContain('id: "terminal"');
|
|
||||||
expect(managerSource).toContain('id: "settings"');
|
|
||||||
expect(managerSource).toContain("import EdgeGatewayOverviewPage");
|
expect(managerSource).toContain("import EdgeGatewayOverviewPage");
|
||||||
expect(managerSource).toContain("import EdgeGatewayInventoryPage");
|
expect(managerSource).toContain("import EdgeGatewayInventoryPage");
|
||||||
expect(managerSource).toContain("import EdgeGatewayTasksPage");
|
expect(managerSource).toContain("import EdgeGatewayTasksPage");
|
||||||
expect(managerSource).toContain("import EdgeGatewayLogsPage");
|
expect(managerSource).toContain("import EdgeGatewayLogsPage");
|
||||||
expect(managerSource).toContain("import EdgeGatewayStatisticsPage");
|
expect(managerSource).toContain("import EdgeGatewayStatisticsPage");
|
||||||
expect(managerSource).toContain("import EdgeGatewayTerminalPage");
|
expect(managerSource).toContain("import EdgeGatewayTerminalPage");
|
||||||
expect(managerSource).toContain("import EdgeGatewaySettingsPage");
|
expect(managerSource).toContain("import EdgeGatewayManagePage");
|
||||||
|
|
||||||
expect(overviewSource).toContain('data-testid="gateway-overview-page"');
|
expect(overviewSource).toContain('data-testid="gateway-overview-page"');
|
||||||
expect(overviewSource).toContain('data-testid="gateway-open-full-page"');
|
|
||||||
expect(overviewSource).toContain('data-testid="gateway-overview-container-health"');
|
expect(overviewSource).toContain('data-testid="gateway-overview-container-health"');
|
||||||
expect(overviewSource).toContain('data-testid="gateway-overview-container-services"');
|
expect(overviewSource).toContain('data-testid="gateway-overview-container-services"');
|
||||||
expect(overviewSource).toContain('data-testid="gateway-overview-outbox"');
|
expect(overviewSource).toContain('data-testid="gateway-overview-outbox"');
|
||||||
@@ -66,20 +80,34 @@ describe("edge gateway workspace contract", () => {
|
|||||||
expect(logsSource).toContain('data-testid="gateway-logs-page"');
|
expect(logsSource).toContain('data-testid="gateway-logs-page"');
|
||||||
expect(statisticsSource).toContain('data-testid="gateway-statistics-page"');
|
expect(statisticsSource).toContain('data-testid="gateway-statistics-page"');
|
||||||
expect(terminalSource).toContain('data-testid="gateway-terminal-page"');
|
expect(terminalSource).toContain('data-testid="gateway-terminal-page"');
|
||||||
expect(terminalSource).toContain('data-testid="gateway-terminal-status"');
|
expect(manageSource).toContain('data-testid="gateway-settings-page"');
|
||||||
expect(settingsSource).toContain('data-testid="gateway-settings-page"');
|
expect(manageSource).toContain('data-testid="gateway-rotate-confirmation"');
|
||||||
expect(settingsSource).toContain('data-testid="gateway-rotate-confirmation"');
|
expect(manageSource).toContain('data-testid="gateway-delete-confirmation"');
|
||||||
expect(settingsSource).toContain('data-testid="gateway-delete-confirmation"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mounts a module workspace and a safe-subset department workspace", () => {
|
it("mounts a module workspace and a safe-subset department workspace", () => {
|
||||||
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
|
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
|
||||||
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
|
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
|
||||||
|
expect(edgeGatewaysPageSource).toContain("import EdgeGatewayFleetLanding");
|
||||||
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
|
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
|
||||||
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
|
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
|
||||||
expect(departmentGatewaysPageSource).toContain(':route-driven="false"');
|
expect(departmentGatewaysPageSource).toContain("import EdgeGatewayDepartmentWorkspace");
|
||||||
expect(departmentGatewaysPageSource).toContain(':allow-destructive="false"');
|
expect(departmentGatewaysPageSource).toContain("Department Hardware Workspace");
|
||||||
expect(departmentGatewaysPageSource).toContain('@open-gateway-page="openGatewayPage"');
|
expect(departmentGatewaysPageSource).toContain("<EdgeGatewayDepartmentWorkspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds department-centric fleet landing and integrated hardware workspace surfaces", () => {
|
||||||
|
expect(fleetLandingSource).toContain('data-testid="hardware-fleet-landing"');
|
||||||
|
expect(fleetLandingSource).toContain("hardware-fleet-card-departments");
|
||||||
|
expect(fleetLandingSource).toContain("hardware-fleet-open-workspace");
|
||||||
|
expect(departmentWorkspaceSource).toContain('data-testid="department-hardware-workspace"');
|
||||||
|
expect(departmentWorkspaceSource).toContain(':data-testid="`department-hardware-tab-${tab.id}`"');
|
||||||
|
expect(departmentWorkspaceSource).toContain('{ id: "gateways", label: "Gateways" }');
|
||||||
|
expect(departmentWorkspaceSource).toContain('data-testid="department-hardware-panel-scanners"');
|
||||||
|
expect(departmentWorkspaceSource).toContain(':data-testid="`department-scanner-save-${scanner.id}`"');
|
||||||
|
expect(departmentWorkspaceSource).toContain(':data-testid="`department-scanner-rotate-${scanner.id}`"');
|
||||||
|
expect(departmentWorkspaceSource).toContain("Rotate API Key");
|
||||||
|
expect(departmentWorkspaceSource).toContain("Open Fleet Landing");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers canonical module routes and keeps legacy redirects", () => {
|
it("registers canonical module routes and keeps legacy redirects", () => {
|
||||||
@@ -97,13 +125,9 @@ describe("edge gateway workspace contract", () => {
|
|||||||
expect(routerSource).toContain("name: 'edgegatewayinventory'");
|
expect(routerSource).toContain("name: 'edgegatewayinventory'");
|
||||||
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/inventory'");
|
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/inventory'");
|
||||||
expect(routerSource).toContain("name: 'edgegatewayoperations'");
|
expect(routerSource).toContain("name: 'edgegatewayoperations'");
|
||||||
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/operations'");
|
|
||||||
expect(routerSource).toContain("/tasks`");
|
|
||||||
expect(routerSource).toContain("name: 'edgegatewaysettings'");
|
expect(routerSource).toContain("name: 'edgegatewaysettings'");
|
||||||
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/settings'");
|
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/settings'");
|
||||||
expect(routerSource).toContain("name: 'edgegatewaymanage'");
|
expect(routerSource).toContain("name: 'edgegatewaymanage'");
|
||||||
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/manage'");
|
|
||||||
expect(routerSource).toContain("/settings`");
|
|
||||||
expect(routerSource).toContain("path: '/superuser/gateways'");
|
expect(routerSource).toContain("path: '/superuser/gateways'");
|
||||||
expect(routerSource).toContain("configure: 'inventory'");
|
expect(routerSource).toContain("configure: 'inventory'");
|
||||||
expect(routerSource).toContain("assess: 'overview'");
|
expect(routerSource).toContain("assess: 'overview'");
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ const recordPageSource = readFileSync(join(root, "src/views/search/SystemSearchR
|
|||||||
|
|
||||||
describe("system search generic record route contract", () => {
|
describe("system search generic record route contract", () => {
|
||||||
it("registers authenticated generic record route in router", () => {
|
it("registers authenticated generic record route in router", () => {
|
||||||
expect(routerSource).toContain('import SystemSearchRecordPage from "@/views/search/SystemSearchRecordPage.vue";');
|
expect(routerSource).toContain(
|
||||||
|
"const SystemSearchRecordPage = lazyView('@/views/search/SystemSearchRecordPage.vue');"
|
||||||
|
);
|
||||||
expect(routerSource).toContain("name: 'systemsearchrecord'");
|
expect(routerSource).toContain("name: 'systemsearchrecord'");
|
||||||
expect(routerSource).toContain("path: '/search/system/record/:entityType/:entityId'");
|
expect(routerSource).toContain("path: '/search/system/record/:entityType/:entityId'");
|
||||||
expect(routerSource).toContain("component: SystemSearchRecordPage");
|
expect(routerSource).toContain("component: SystemSearchRecordPage");
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("weatherapi superuser wiring", () => {
|
|||||||
|
|
||||||
it("has a dedicated superuser configuration route", () => {
|
it("has a dedicated superuser configuration route", () => {
|
||||||
expect(routerSource).toContain(
|
expect(routerSource).toContain(
|
||||||
'import ConfigurationWeatherAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue";'
|
"const ConfigurationWeatherAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue');"
|
||||||
);
|
);
|
||||||
expect(routerSource).toContain("name: 'configurationWeatherAPI'");
|
expect(routerSource).toContain("name: 'configurationWeatherAPI'");
|
||||||
expect(routerSource).toContain("path: '/superuser/configuration/weatherapi'");
|
expect(routerSource).toContain("path: '/superuser/configuration/weatherapi'");
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe("workfeed superuser wiring", () => {
|
|||||||
|
|
||||||
it("has a dedicated superuser configuration route", () => {
|
it("has a dedicated superuser configuration route", () => {
|
||||||
expect(routerSource).toContain(
|
expect(routerSource).toContain(
|
||||||
'import ConfigurationWorkfeed from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue";'
|
"const ConfigurationWorkfeed = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue');"
|
||||||
);
|
);
|
||||||
expect(routerSource).toContain("name: 'configurationWorkfeed'");
|
expect(routerSource).toContain("name: 'configurationWorkfeed'");
|
||||||
expect(routerSource).toContain("path: '/superuser/configuration/workfeed'");
|
expect(routerSource).toContain("path: '/superuser/configuration/workfeed'");
|
||||||
|
|||||||
+54
-8
@@ -1,9 +1,10 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import fs from 'node:fs'
|
||||||
|
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,9 +61,44 @@ function patchBuefyCssMediaQuery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
function stripBrokenVueToastNotificationSourceMap() {
|
||||||
|
return {
|
||||||
|
name: 'strip-broken-vue-toast-notification-sourcemap',
|
||||||
|
enforce: 'pre',
|
||||||
|
load(id) {
|
||||||
|
if (id.startsWith('\0') || !id.includes('vue-toast-notification/dist/index.js')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = id.split('?')[0]
|
||||||
|
const code = fs.readFileSync(filePath, 'utf8')
|
||||||
|
return code.replace(/\n?\/\/\# sourceMappingURL=mitt\.mjs\.map\s*$/m, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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'
|
||||||
@@ -101,11 +137,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
base,
|
base,
|
||||||
plugins: [
|
plugins: [
|
||||||
patchBuefyCssMediaQuery(),
|
patchBuefyCssMediaQuery(),
|
||||||
|
stripBrokenVueToastNotificationSourceMap(),
|
||||||
vue(),
|
vue(),
|
||||||
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',
|
||||||
@@ -194,8 +231,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
})
|
})
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
resolve: {
|
resolve: {
|
||||||
|
preserveSymlinks: true,
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
// Anchor aliases to the active workspace so hardlinked worktrees do not
|
||||||
|
// accidentally resolve into a sibling checkout.
|
||||||
|
'@': path.resolve(workspaceRoot, 'src')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
@@ -204,6 +244,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(commit),
|
'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(commit),
|
||||||
// Tip: import.meta.env.DEV/PROD are available at runtime
|
// Tip: import.meta.env.DEV/PROD are available at runtime
|
||||||
'import.meta.env.VITE_IS_DEV': JSON.stringify(!isProd),
|
'import.meta.env.VITE_IS_DEV': JSON.stringify(!isProd),
|
||||||
|
'import.meta.env.VITE_IS_PLAYWRIGHT': JSON.stringify(isPlaywrightRuntime),
|
||||||
},
|
},
|
||||||
css: {
|
css: {
|
||||||
preprocessorOptions: {
|
preprocessorOptions: {
|
||||||
@@ -219,11 +260,16 @@ export default defineConfig(({ mode }) => {
|
|||||||
ignored: ['**/output/playwright/**']
|
ignored: ['**/output/playwright/**']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
optimizeDeps: isPlaywrightRuntime
|
optimizeDeps: shouldDisableDepOptimizer
|
||||||
? {
|
? {
|
||||||
noDiscovery: true
|
noDiscovery: true,
|
||||||
|
include: []
|
||||||
}
|
}
|
||||||
: undefined
|
: isPlaywrightRuntime
|
||||||
|
? {
|
||||||
|
entries: ['index.html']
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user