Compare commits

...
26 changed files with 2842 additions and 806 deletions
+43
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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"
@@ -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>
+20 -1
View File
@@ -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();
+40
View File
@@ -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,
+1
View File
@@ -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();
+704 -3
View File
@@ -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);
+67
View File
@@ -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", () => {
+59 -35
View File
@@ -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");
+1 -1
View File
@@ -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'");
+1 -1
View File
@@ -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
View File
@@ -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
} }
}) })