Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a6fe302d | ||
|
|
eb2d119823 | ||
|
|
f7e845b3d2 | ||
|
|
88c65776ad | ||
|
|
17937200f0 | ||
|
|
b26e50cd1e |
@@ -99,6 +99,49 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
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:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
|
||||
needs: build-and-unit
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { loadList } from "@/components/pagination/paginatedList.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');
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -23,7 +23,8 @@ export const createScanner = (department_id, name, notes) => {
|
||||
return axios.post(API_URL + '/numberplatescanners', {
|
||||
department_id,
|
||||
name,
|
||||
notes
|
||||
notes,
|
||||
...(lane_id !== undefined ? { lane_id } : {})
|
||||
}, {
|
||||
headers: {
|
||||
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');
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -40,7 +41,8 @@ export const editScanner = (id, department_id, name, notes) => {
|
||||
id,
|
||||
department_id,
|
||||
name,
|
||||
notes
|
||||
notes,
|
||||
...(lane_id !== undefined ? { lane_id } : {})
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
|
||||
@@ -27,7 +27,27 @@ const validateDepartmentGateConfig = (config) => {
|
||||
const normalizedType = String(normalizedConfig?.type ?? '').trim().toUpperCase();
|
||||
|
||||
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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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">
|
||||
<p class="edge-gateway-manage__eyebrow">Metadata</p>
|
||||
<h3>Gateway identity</h3>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,15 +6,21 @@ const props = defineProps({
|
||||
type: Object,
|
||||
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 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 diagnostics = computed(() => props.gateway?.diagnostics || []);
|
||||
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>
|
||||
<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">
|
||||
<article class="edge-gateway-overview__card">
|
||||
<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">
|
||||
<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>
|
||||
</article>
|
||||
|
||||
@@ -121,11 +116,6 @@ const metrics = computed(() => props.gateway?.metadata?.system_metrics || props.
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.edge-gateway-overview__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.edge-gateway-overview__grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -33,7 +33,27 @@ const cards = computed(() => {
|
||||
|
||||
const channelStatus = computed(() => props.statistics?.channel_status || {});
|
||||
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 || {});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
operations: {
|
||||
@@ -72,6 +72,32 @@ const streamLabel = computed(() =>
|
||||
idle: "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>
|
||||
|
||||
<template>
|
||||
@@ -107,11 +133,11 @@ const streamLabel = computed(() =>
|
||||
<input
|
||||
class="input"
|
||||
data-testid="gateway-update-target-version"
|
||||
:value="updateTargetVersion"
|
||||
:value="localTargetVersion"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
|
||||
+159
-151
@@ -7,173 +7,181 @@ import authMiddleware from './middleware/authMiddleware.js';
|
||||
import guestMiddleware from "@/middleware/guestMiddleware.js";
|
||||
import adminMiddleware from './middleware/adminMiddleware.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 */
|
||||
/**
|
||||
* Default page
|
||||
*/
|
||||
import DefaultPage from "@/views/DefaultPage.vue";
|
||||
const DefaultPage = lazyView('@/views/DefaultPage.vue');
|
||||
/** Auth */
|
||||
import Login from './views/auth/Login.vue';
|
||||
import Register from './views/auth/Register.vue';
|
||||
import LoginQR from "@/views/auth/LoginQR.vue";
|
||||
const Login = lazyView('./views/auth/Login.vue');
|
||||
const Register = lazyView('./views/auth/Register.vue');
|
||||
const LoginQR = lazyView('@/views/auth/LoginQR.vue');
|
||||
/** Dashboards */
|
||||
import SuperUserDashboard from './views/dashboards/SuperUserDashboard.vue';
|
||||
import DepartmentDashboard from './views/dashboards/DepartmentDashboard.vue';
|
||||
import UserDashboard from './views/dashboards/UserDashboard.vue';
|
||||
const SuperUserDashboard = lazyView('./views/dashboards/SuperUserDashboard.vue');
|
||||
const DepartmentDashboard = lazyView('./views/dashboards/DepartmentDashboard.vue');
|
||||
const UserDashboard = lazyView('./views/dashboards/UserDashboard.vue');
|
||||
|
||||
/** Dashboard: SuperUser */
|
||||
import Departments from "@/views/dashboards/superUserDashboard/Departments.vue";
|
||||
import DepartmentLanes from "@/views/dashboards/superUserDashboard/DepartmentLanes.vue";
|
||||
import DepartmentGates from "@/views/dashboards/superUserDashboard/DepartmentGates.vue";
|
||||
import DepartmentRelays from "@/views/dashboards/superUserDashboard/DepartmentRelays.vue";
|
||||
import DepartmentLane from "@/views/dashboards/superUserDashboard/department/lanes/DepartmentLane.vue";
|
||||
import Users from "@/views/dashboards/superUserDashboard/Users.vue";
|
||||
import Products from "@/views/dashboards/superUserDashboard/Products.vue";
|
||||
import Orders from "@/views/dashboards/superUserDashboard/Orders.vue";
|
||||
import OrdersDrafts from "@/views/dashboards/superUserDashboard/OrdersDrafts.vue";
|
||||
import Bookings from "@/views/dashboards/superUserDashboard/Bookings.vue";
|
||||
import NumberPlateScanners from "@/views/dashboards/superUserDashboard/NumberPlateScanners.vue";
|
||||
import Statistics from "@/views/dashboards/superUserDashboard/statistics/Statistics.vue";
|
||||
import DepartmentSelfServe from "@/views/dashboards/superUserDashboard/DepartmentSelfServe.vue";
|
||||
import EdgeGatewaysWorkspacePage from "@/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue";
|
||||
import Invoicing from "@/views/dashboards/superUserDashboard/Invoicing.vue";
|
||||
const Departments = lazyView('@/views/dashboards/superUserDashboard/Departments.vue');
|
||||
const DepartmentLanes = lazyView('@/views/dashboards/superUserDashboard/DepartmentLanes.vue');
|
||||
const DepartmentGates = lazyView('@/views/dashboards/superUserDashboard/DepartmentGates.vue');
|
||||
const DepartmentRelays = lazyView('@/views/dashboards/superUserDashboard/DepartmentRelays.vue');
|
||||
const DepartmentLane = lazyView('@/views/dashboards/superUserDashboard/department/lanes/DepartmentLane.vue');
|
||||
const Users = lazyView('@/views/dashboards/superUserDashboard/Users.vue');
|
||||
const Products = lazyView('@/views/dashboards/superUserDashboard/Products.vue');
|
||||
const Orders = lazyView('@/views/dashboards/superUserDashboard/Orders.vue');
|
||||
const OrdersDrafts = lazyView('@/views/dashboards/superUserDashboard/OrdersDrafts.vue');
|
||||
const Bookings = lazyView('@/views/dashboards/superUserDashboard/Bookings.vue');
|
||||
const NumberPlateScanners = lazyView('@/views/dashboards/superUserDashboard/NumberPlateScanners.vue');
|
||||
const Statistics = lazyView('@/views/dashboards/superUserDashboard/statistics/Statistics.vue');
|
||||
const DepartmentSelfServe = lazyView('@/views/dashboards/superUserDashboard/DepartmentSelfServe.vue');
|
||||
const EdgeGatewaysWorkspacePage = lazyView('@/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue');
|
||||
const Invoicing = lazyView('@/views/dashboards/superUserDashboard/Invoicing.vue');
|
||||
|
||||
/** Dashboard: Department */
|
||||
import DepartmentPos from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPos.vue";
|
||||
import DepartmentPosDrafts from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosDrafts.vue";
|
||||
import DepartmentPosOrders from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrders.vue";
|
||||
import DepartmentPosOrder from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrder.vue";
|
||||
import DepartmentPosSyncDuplicates from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSyncDuplicates.vue";
|
||||
//import DepartmentIntranet from "@/views/dashboards/departmentDashboard/modules/intranet/DepartmentIntranet.vue";
|
||||
const DepartmentPos = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPos.vue');
|
||||
const DepartmentPosDrafts = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosDrafts.vue');
|
||||
const DepartmentPosOrders = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrders.vue');
|
||||
const DepartmentPosOrder = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrder.vue');
|
||||
const DepartmentPosSyncDuplicates = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSyncDuplicates.vue');
|
||||
//const DepartmentIntranet = lazyView('@/views/dashboards/departmentDashboard/modules/intranet/DepartmentIntranet.vue');
|
||||
|
||||
/**
|
||||
* Dashboard: User
|
||||
*/
|
||||
import MyVehicles from "@/views/dashboards/userDashboard/vehicles/MyVehicles.vue";
|
||||
import MyMaterials from "@/views/dashboards/userDashboard/materials/MyMaterials.vue";
|
||||
const MyVehicles = lazyView('@/views/dashboards/userDashboard/vehicles/MyVehicles.vue');
|
||||
const MyMaterials = lazyView('@/views/dashboards/userDashboard/materials/MyMaterials.vue');
|
||||
|
||||
/** Errors */
|
||||
import NotFound from './views/errors/NotFound.vue';
|
||||
import ConnectivityIssue from './views/errors/ConnectivityIssue.vue';
|
||||
import OutdatedInstallation from "@/views/errors/OutdatedInstallation.vue";
|
||||
import OutdatedGateway from "@/views/errors/OutdatedGateway.vue";
|
||||
import MyOrders from "@/views/dashboards/userDashboard/orders/MyOrders.vue";
|
||||
import NewVehicle from "@/views/dashboards/userDashboard/vehicles/NewVehicle.vue";
|
||||
import MyBookings from "@/views/dashboards/userDashboard/bookings/MyBookings.vue";
|
||||
import DatabaseOverview from "@/views/dashboards/superUserDashboard/system/DatabaseOverview.vue";
|
||||
import DepartmentBookings from "@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookings.vue";
|
||||
import Employee from "@/views/auth/Employee.vue";
|
||||
import User from "@/views/dashboards/superUserDashboard/user/User.vue";
|
||||
import UserOrders from "@/views/dashboards/superUserDashboard/user/UserOrders.vue";
|
||||
import UserPricing from "@/views/dashboards/superUserDashboard/user/UserPricing.vue";
|
||||
import Department from "@/views/dashboards/superUserDashboard/department/Department.vue";
|
||||
import DepartmentPricing from "@/views/dashboards/superUserDashboard/department/DepartmentPricing.vue";
|
||||
import MyOrder from "@/views/dashboards/userDashboard/orders/MyOrder.vue";
|
||||
import StatisticsOverview from "@/views/dashboards/superUserDashboard/statistics/StatisticsOverview.vue";
|
||||
import Configuration from "@/views/dashboards/superUserDashboard/configuration/Configuration.vue";
|
||||
import ConfigurationEconomic from "@/views/dashboards/superUserDashboard/configuration/ConfigurationEconomic.vue";
|
||||
import ConfigurationReCAPTCHA from "@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue";
|
||||
import ConfigurationEmail from "@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue";
|
||||
import ConfigurationBackups from "@/views/dashboards/superUserDashboard/configuration/ConfigurationBackups.vue";
|
||||
import Categories from "@/views/dashboards/superUserDashboard/Categories.vue";
|
||||
import Customers from "@/views/dashboards/superUserDashboard/Customers.vue";
|
||||
import CustomerComplaints from "@/views/dashboards/superUserDashboard/CustomerComplaints.vue";
|
||||
import DepartmentCategories from "@/views/dashboards/superUserDashboard/department/DepartmentCategories.vue";
|
||||
import ConfigurationMotorAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationMotorAPI.vue";
|
||||
import ConfigurationStripe from "@/views/dashboards/superUserDashboard/configuration/ConfigurationStripe.vue";
|
||||
import Roles from "@/views/dashboards/superUserDashboard/roles/Roles.vue";
|
||||
import SuperUserRolesPermissions from "@/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue";
|
||||
import DepartmentStripeTerminalsReaders from "@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue";
|
||||
import DepartmentStripeSetup from "@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue";
|
||||
import CollectedOrderInvoices from "@/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue";
|
||||
import CollectedOrderInvoice
|
||||
from "@/views/dashboards/superUserDashboard/collectedOrderInvoice/collectedOrderInvoice.vue";
|
||||
import InvoiceDistributionMonthView
|
||||
from "@/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue";
|
||||
import DepartmentDailyReport
|
||||
from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue";
|
||||
import SuperUserDashboardProduct from "@/views/dashboards/superUserDashboard/products/SuperUserDashboardProduct.vue";
|
||||
import UserOther from "@/views/dashboards/superUserDashboard/user/UserOther.vue";
|
||||
//import MyBookingsNew from "@/views/dashboards/userDashboard/bookings/MyBookingsNew.vue";
|
||||
import DepartmentCompleteBooking
|
||||
from "@/views/dashboards/departmentDashboard/modules/bookings/DepartmentCompleteBooking.vue";
|
||||
import GuestHome from "@/views/guest/GuestHome.vue";
|
||||
import GuestBookExteriorWash from "@/views/guest/book/GuestBookExteriorWash.vue";
|
||||
import DepartmentProfile from "@/views/dashboards/superUserDashboard/department/DepartmentProfile.vue";
|
||||
import DepartmentGatewaysWorkspacePage from "@/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue";
|
||||
import ConfigurationFXRatesAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue";
|
||||
import ConfigurationWeatherAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue";
|
||||
import ConfigurationWorkfeed from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue";
|
||||
import ConfigurationGatewayAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationGatewayAPI.vue";
|
||||
import UserWashSubscriptions from "@/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue";
|
||||
import UserProfile from "@/views/dashboards/userDashboard/profile/UserProfile.vue";
|
||||
import DepartmentNotifications
|
||||
from "@/views/dashboards/departmentDashboard/modules/notifications/DepartmentNotifications.vue";
|
||||
import Vehicles from "@/views/dashboards/superUserDashboard/Vehicles.vue";
|
||||
import ConfigurationXLVask from "@/views/dashboards/superUserDashboard/configuration/ConfigurationXLVask.vue";
|
||||
import UserXLVask from "@/views/dashboards/superUserDashboard/user/UserXLVask.vue";
|
||||
import DepartmentTimeBookings
|
||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookings.vue";
|
||||
import DepartmentTimeBookingsOpeningHours
|
||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue";
|
||||
import DepartmentTimeBookingsTypes
|
||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsTypes.vue";
|
||||
import DepartmentModulesSetup
|
||||
from "@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue";
|
||||
import Vehicle from "@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue";
|
||||
import MyInvoices from "@/views/dashboards/userDashboard/invoices/MyInvoices.vue";
|
||||
import MicrosoftCallbackToken from "@/views/callback/microsoft/MicrosoftCallbackToken.vue";
|
||||
import ConfigurationEntra from "@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue";
|
||||
import DepartmentTimeBookingsNew
|
||||
from "@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue";
|
||||
import DepartmentWashLanes from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLanes.vue";
|
||||
import DepartmentWashLane from "@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLane.vue";
|
||||
import DepartmentSelfServeStudio
|
||||
from "@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue";
|
||||
import DepartmentGoals
|
||||
from "@/views/dashboards/departmentDashboard/modules/goals/DepartmentGoals.vue";
|
||||
import ConfigurationLimble from "@/views/dashboards/superUserDashboard/configuration/ConfigurationLimble.vue";
|
||||
import MyVehicle from "@/views/dashboards/userDashboard/vehicles/MyVehicle.vue";
|
||||
import XLVaskPage from "@/views/dashboards/superUserDashboard/XLVaskPage.vue";
|
||||
import XLVaskCustomersPage from "@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskCustomersPage.vue";
|
||||
import XLVaskUsageLogsPage from "@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskUsageLogsPage.vue";
|
||||
import DepartmentPosSync from "@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue";
|
||||
import PWADownload from "@/views/guest/pwa/PWADownload.vue";
|
||||
import Logout from "@/views/auth/Logout.vue";
|
||||
//import ReaderReliabilityTest from "@/views/dashboards/departmentDashboard/modules/debug/ReaderReliabilityTest.vue";
|
||||
import ConfigurationOcrSpace from "@/views/dashboards/superUserDashboard/configuration/ConfigurationOcrSpace.vue";
|
||||
import ConfigurationOpenAI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationOpenAI.vue";
|
||||
import ConfigurationLicensePlateRecognizer from "@/views/dashboards/superUserDashboard/configuration/ConfigurationLicensePlateRecognizer.vue";
|
||||
import ConfigurationVirkData from "@/views/dashboards/superUserDashboard/configuration/ConfigurationVirkData.vue";
|
||||
import ConfigurationShelly from "@/views/dashboards/superUserDashboard/configuration/ConfigurationShelly.vue";
|
||||
import MyBookingsBook from "@/views/dashboards/userDashboard/bookings/MyBookingsBook.vue";
|
||||
import ReturnToOwnAccount from "@/views/auth/ReturnToOwnAccount.vue";
|
||||
import DepartmentDashboardOrderBooking
|
||||
from "@/views/dashboards/departmentDashboard/modules/order-bookings/DepartmentDashboardOrderBooking.vue";
|
||||
import DepartmentBookingsLegacy
|
||||
from "@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookingsLegacy.vue";
|
||||
import MyBookingsLegacy from "@/views/dashboards/userDashboard/bookings/MyBookingsLegacy.vue";
|
||||
import ConfigurationSelfServe from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSelfServe.vue";
|
||||
import ConfigurationBird from "@/views/dashboards/superUserDashboard/configuration/ConfigurationBird.vue";
|
||||
import MyWash from "@/views/dashboards/userDashboard/wash/MyWash.vue";
|
||||
import MyWashStart from "@/views/dashboards/userDashboard/wash/MyWashStart.vue";
|
||||
import LandingPage from "@/views/pages/LandingPage.vue";
|
||||
import BookDemoPage from "@/views/pages/BookDemoPage.vue";
|
||||
import AboutUsPage from "@/views/pages/AboutUsPage.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";
|
||||
const NotFound = lazyView('./views/errors/NotFound.vue');
|
||||
const ConnectivityIssue = lazyView('./views/errors/ConnectivityIssue.vue');
|
||||
const OutdatedInstallation = lazyView('@/views/errors/OutdatedInstallation.vue');
|
||||
const OutdatedGateway = lazyView('@/views/errors/OutdatedGateway.vue');
|
||||
const MyOrders = lazyView('@/views/dashboards/userDashboard/orders/MyOrders.vue');
|
||||
const NewVehicle = lazyView('@/views/dashboards/userDashboard/vehicles/NewVehicle.vue');
|
||||
const MyBookings = lazyView('@/views/dashboards/userDashboard/bookings/MyBookings.vue');
|
||||
const DatabaseOverview = lazyView('@/views/dashboards/superUserDashboard/system/DatabaseOverview.vue');
|
||||
const DepartmentBookings = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookings.vue');
|
||||
const Employee = lazyView('@/views/auth/Employee.vue');
|
||||
const User = lazyView('@/views/dashboards/superUserDashboard/user/User.vue');
|
||||
const UserOrders = lazyView('@/views/dashboards/superUserDashboard/user/UserOrders.vue');
|
||||
const UserPricing = lazyView('@/views/dashboards/superUserDashboard/user/UserPricing.vue');
|
||||
const Department = lazyView('@/views/dashboards/superUserDashboard/department/Department.vue');
|
||||
const DepartmentPricing = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentPricing.vue');
|
||||
const MyOrder = lazyView('@/views/dashboards/userDashboard/orders/MyOrder.vue');
|
||||
const StatisticsOverview = lazyView('@/views/dashboards/superUserDashboard/statistics/StatisticsOverview.vue');
|
||||
const Configuration = lazyView('@/views/dashboards/superUserDashboard/configuration/Configuration.vue');
|
||||
const ConfigurationEconomic = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEconomic.vue');
|
||||
const ConfigurationReCAPTCHA = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationReCAPTCHA.vue');
|
||||
const ConfigurationEmail = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEmail.vue');
|
||||
const ConfigurationBackups = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationBackups.vue');
|
||||
const Categories = lazyView('@/views/dashboards/superUserDashboard/Categories.vue');
|
||||
const Customers = lazyView('@/views/dashboards/superUserDashboard/Customers.vue');
|
||||
const CustomerComplaints = lazyView('@/views/dashboards/superUserDashboard/CustomerComplaints.vue');
|
||||
const DepartmentCategories = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentCategories.vue');
|
||||
const ConfigurationMotorAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationMotorAPI.vue');
|
||||
const ConfigurationStripe = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationStripe.vue');
|
||||
const Roles = lazyView('@/views/dashboards/superUserDashboard/roles/Roles.vue');
|
||||
const SuperUserRolesPermissions = lazyView('@/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue');
|
||||
const DepartmentStripeTerminalsReaders = lazyView('@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeTerminalsReaders.vue');
|
||||
const DepartmentStripeSetup = lazyView('@/views/dashboards/superUserDashboard/department/stripe/DepartmentStripeSetup.vue');
|
||||
const CollectedOrderInvoices = lazyView('@/views/dashboards/superUserDashboard/CollectedOrderInvoices.vue');
|
||||
const CollectedOrderInvoice = lazyView('@/views/dashboards/superUserDashboard/collectedOrderInvoice/collectedOrderInvoice.vue');
|
||||
const InvoiceDistributionMonthView = lazyView('@/views/dashboards/superUserDashboard/invoiceDistribution/InvoiceDistributionMonthView.vue');
|
||||
const DepartmentDailyReport = lazyView('@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReport.vue');
|
||||
const SuperUserDashboardProduct = lazyView('@/views/dashboards/superUserDashboard/products/SuperUserDashboardProduct.vue');
|
||||
const UserOther = lazyView('@/views/dashboards/superUserDashboard/user/UserOther.vue');
|
||||
//const MyBookingsNew = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsNew.vue');
|
||||
const DepartmentCompleteBooking = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentCompleteBooking.vue');
|
||||
const GuestHome = lazyView('@/views/guest/GuestHome.vue');
|
||||
const GuestBookExteriorWash = lazyView('@/views/guest/book/GuestBookExteriorWash.vue');
|
||||
const DepartmentProfile = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentProfile.vue');
|
||||
const DepartmentGatewaysWorkspacePage = lazyView('@/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue');
|
||||
const ConfigurationFXRatesAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue');
|
||||
const ConfigurationWeatherAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue');
|
||||
const ConfigurationWorkfeed = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue');
|
||||
const ConfigurationGatewayAPI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationGatewayAPI.vue');
|
||||
const UserWashSubscriptions = lazyView('@/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue');
|
||||
const UserProfile = lazyView('@/views/dashboards/userDashboard/profile/UserProfile.vue');
|
||||
const DepartmentNotifications = lazyView('@/views/dashboards/departmentDashboard/modules/notifications/DepartmentNotifications.vue');
|
||||
const Vehicles = lazyView('@/views/dashboards/superUserDashboard/Vehicles.vue');
|
||||
const ConfigurationXLVask = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationXLVask.vue');
|
||||
const UserXLVask = lazyView('@/views/dashboards/superUserDashboard/user/UserXLVask.vue');
|
||||
const DepartmentTimeBookings = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookings.vue');
|
||||
const DepartmentTimeBookingsOpeningHours = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsOpeningHours.vue');
|
||||
const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/DepartmentTimeBookingsTypes.vue');
|
||||
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
|
||||
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue');
|
||||
const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue');
|
||||
const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue');
|
||||
const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
|
||||
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
|
||||
const DepartmentWashLanes = lazyView('@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLanes.vue');
|
||||
const DepartmentWashLane = lazyView('@/views/dashboards/departmentDashboard/modules/wash-lanes/DepartmentWashLane.vue');
|
||||
const DepartmentSelfServeStudio = lazyView('@/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue');
|
||||
const DepartmentGoals = lazyView('@/views/dashboards/departmentDashboard/modules/goals/DepartmentGoals.vue');
|
||||
const ConfigurationLimble = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationLimble.vue');
|
||||
const MyVehicle = lazyView('@/views/dashboards/userDashboard/vehicles/MyVehicle.vue');
|
||||
const XLVaskPage = lazyView('@/views/dashboards/superUserDashboard/XLVaskPage.vue');
|
||||
const XLVaskCustomersPage = lazyView('@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskCustomersPage.vue');
|
||||
const XLVaskUsageLogsPage = lazyView('@/views/dashboards/superUserDashboard/xlvask/pages/XLVaskUsageLogsPage.vue');
|
||||
const DepartmentPosSync = lazyView('@/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue');
|
||||
const PWADownload = lazyView('@/views/guest/pwa/PWADownload.vue');
|
||||
const Logout = lazyView('@/views/auth/Logout.vue');
|
||||
//const ReaderReliabilityTest = lazyView('@/views/dashboards/departmentDashboard/modules/debug/ReaderReliabilityTest.vue');
|
||||
const ConfigurationOcrSpace = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationOcrSpace.vue');
|
||||
const ConfigurationOpenAI = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationOpenAI.vue');
|
||||
const ConfigurationLicensePlateRecognizer = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationLicensePlateRecognizer.vue');
|
||||
const ConfigurationVirkData = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationVirkData.vue');
|
||||
const ConfigurationShelly = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationShelly.vue');
|
||||
const MyBookingsBook = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsBook.vue');
|
||||
const ReturnToOwnAccount = lazyView('@/views/auth/ReturnToOwnAccount.vue');
|
||||
const DepartmentDashboardOrderBooking = lazyView('@/views/dashboards/departmentDashboard/modules/order-bookings/DepartmentDashboardOrderBooking.vue');
|
||||
const DepartmentBookingsLegacy = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookingsLegacy.vue');
|
||||
const MyBookingsLegacy = lazyView('@/views/dashboards/userDashboard/bookings/MyBookingsLegacy.vue');
|
||||
const ConfigurationSelfServe = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationSelfServe.vue');
|
||||
const ConfigurationBird = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationBird.vue');
|
||||
const MyWash = lazyView('@/views/dashboards/userDashboard/wash/MyWash.vue');
|
||||
const MyWashStart = lazyView('@/views/dashboards/userDashboard/wash/MyWashStart.vue');
|
||||
const LandingPage = lazyView('@/views/pages/LandingPage.vue');
|
||||
const BookDemoPage = lazyView('@/views/pages/BookDemoPage.vue');
|
||||
const AboutUsPage = lazyView('@/views/pages/AboutUsPage.vue');
|
||||
const PrivacyPolicyPage = lazyView('@/views/pages/PrivacyPolicyPage.vue');
|
||||
const PasswordResetPage = lazyView('@/views/pages/auth/PasswordResetPage.vue');
|
||||
const PasswordResetConfirmationPage = lazyView('@/views/pages/auth/PasswordResetConfirmationPage.vue');
|
||||
const SubuserCompleteRegistrationPage = lazyView('@/views/pages/auth/SubuserCompleteRegistrationPage.vue');
|
||||
const GuestQRNewCustomer = lazyView('@/views/guest/qr/GuestQRNewCustomer.vue');
|
||||
const GuestQrNewDriver = lazyView('@/views/guest/qr/GuestQRNewDriver.vue');
|
||||
const GuestQRNewDriver = lazyView('@/views/guest/qr/GuestQRNewDriver.vue');
|
||||
const GuestQRNewBooking = lazyView('@/views/guest/qr/GuestQRNewBooking.vue');
|
||||
const CustomerCreationPage = lazyView('@/views/pages/auth/CustomerCreationPage.vue');
|
||||
const Subusers = lazyView('@/views/dashboards/superUserDashboard/Subusers.vue');
|
||||
const SubuserGrants = lazyView('@/views/dashboards/superUserDashboard/SubuserGrants.vue');
|
||||
const SubuserLogin = lazyView('@/views/auth/SubuserLogin.vue');
|
||||
const SystemSearchRecordPage = lazyView('@/views/search/SystemSearchRecordPage.vue');
|
||||
const EconomicQueuePlaywrightHarness = lazyView('@/views/testing/EconomicQueuePlaywrightHarness.vue');
|
||||
|
||||
/**
|
||||
* Meta data for routes
|
||||
|
||||
@@ -357,6 +357,12 @@ export const listEdgeGatewayDepartments = async ({ forceRefresh = false } = {})
|
||||
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 } = {}) => {
|
||||
const requestKey = listCacheKey({ departmentId, view });
|
||||
if (!forceRefresh && listRequestsInFlight.has(requestKey)) {
|
||||
@@ -503,6 +509,15 @@ export const setDepartmentGatewayCutover = async (departmentId, transport_mode)
|
||||
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 () =>
|
||||
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "GET", {}).then((response) => {
|
||||
const entries = normalizeEntries(unwrapEdgeGatewayResponse(response, []));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import EdgeGatewayManager from "@/features/edgeGateways/EdgeGatewayManager.vue";
|
||||
import EdgeGatewayFleetLanding from "@/features/edgeGateways/EdgeGatewayFleetLanding.vue";
|
||||
import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
@@ -179,6 +180,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<EdgeGatewayFleetLanding v-if="!moduleState.unavailable && !selectedGatewayId" />
|
||||
|
||||
<EdgeGatewayManager
|
||||
v-if="!moduleState.unavailable"
|
||||
:selected-gateway-id="selectedGatewayId"
|
||||
|
||||
+5
-18
@@ -1,23 +1,15 @@
|
||||
<script setup>
|
||||
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 RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import DepartmentSubPageWrapper from "@/views/dashboards/superUserDashboard/department/DepartmentSubPageWrapper.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -25,16 +17,11 @@ const openGatewayPage = async (gatewayId) => {
|
||||
<DepartmentSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle
|
||||
title="Gateway for afdelingen"
|
||||
subtitle="Overview, tasks, logs, statistics, and inventory without destructive controls"
|
||||
title="Department Hardware Workspace"
|
||||
subtitle="Overview, self-serve readiness, gates, scanners, and gateway coverage for this department"
|
||||
/>
|
||||
</template>
|
||||
<EdgeGatewayManager
|
||||
:department-id="departmentId"
|
||||
:route-driven="false"
|
||||
:allow-destructive="false"
|
||||
@open-gateway-page="openGatewayPage"
|
||||
/>
|
||||
<EdgeGatewayDepartmentWorkspace :department-id="departmentId" />
|
||||
</DepartmentSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
@@ -49,6 +49,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
||||
test("filters the fleet roster with search and summary chips", async ({ page }) => {
|
||||
await page.goto("/superuser/configuration/edgegateway");
|
||||
|
||||
await expect(page.getByTestId("hardware-fleet-landing")).toBeVisible();
|
||||
await page.getByTestId("gateway-fleet-search").fill("ode");
|
||||
await expect(page.getByTestId("gateway-fleet-item-702")).toBeVisible();
|
||||
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);
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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("gateway-fleet-item-701")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-tab-terminal")).toHaveCount(0);
|
||||
await expect(page.getByTestId("gateway-tab-settings")).toHaveCount(0);
|
||||
await page.getByTestId("gateway-tab-tasks").click();
|
||||
|
||||
@@ -305,6 +305,46 @@ test.describe("Edge gateway management smoke", () => {
|
||||
await expect(page.locator("body")).toContainText("Gateway deleted.");
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
|
||||
@@ -32,6 +32,7 @@ test.describe("Edge gateway visuals", () => {
|
||||
await page.goto("/superuser/configuration/edgegateway");
|
||||
|
||||
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("gateway-fleet-roster")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
|
||||
|
||||
@@ -2114,6 +2114,9 @@ export function createPosFixture(overrides = {}) {
|
||||
paymentIntentsByOrderId: {},
|
||||
stripeReadersError: null,
|
||||
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
|
||||
departmentCategoriesDelayMs: 0,
|
||||
productsDelayMs: 0,
|
||||
productsDelayMsByCategory: {},
|
||||
nextOrderId: 54519,
|
||||
nextOrderItemId: 9200,
|
||||
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 }) {
|
||||
if (!posFixture) {
|
||||
return false;
|
||||
@@ -2259,6 +2276,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/departments/categories") && method === "GET") {
|
||||
await maybeDelayFixtureResponse(posFixture.departmentCategoriesDelayMs);
|
||||
await route.fulfill(json({ success: true, data: posFixture.departmentCategories || [] }));
|
||||
return true;
|
||||
}
|
||||
@@ -2573,6 +2591,14 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
if (pathname.endsWith("/products") && method === "GET") {
|
||||
const productId = Number(parsedUrl.searchParams.get("id") || 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) {
|
||||
await route.fulfill(
|
||||
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") {
|
||||
const orderId = Number(parsedUrl.searchParams.get("order_id") || 0);
|
||||
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || 0);
|
||||
const body = request.postDataJSON?.() || {};
|
||||
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(
|
||||
(attachment) => attachment.id !== attachmentId
|
||||
);
|
||||
@@ -3142,6 +3169,574 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
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 }) {
|
||||
if (!edgeGatewayFixture) {
|
||||
return false;
|
||||
@@ -3167,6 +3762,8 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
||||
return operation;
|
||||
};
|
||||
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 edgeGatewayTasksPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/tasks$/;
|
||||
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 edgeGatewayInstallTokenStatusPattern = /\/(?:modules\/)?edge-gateways\/install-token\/(\d+)\/status$/;
|
||||
const edgeGatewayBindingsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/bindings$/;
|
||||
const numberPlateScannersPattern = /\/numberplatescanners$/;
|
||||
const numberPlateScannerRotatePattern = /\/numberplatescanners\/(\d+)\/rotate-key$/;
|
||||
const edgeGatewayDeletePattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
||||
const edgeGatewayCutoverPattern =
|
||||
/\/(?:modules\/edge-gateways\/departments\/(\d+)\/cutover|departments\/(\d+)\/gateway-cutover)$/;
|
||||
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 = () => [
|
||||
{ variable: "enabled", value: edgeGatewayFixture.config.enabled },
|
||||
{ variable: "default_release_channel", value: edgeGatewayFixture.config.default_release_channel },
|
||||
@@ -3230,6 +3829,108 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
||||
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") {
|
||||
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
||||
const departmentId = Number(parsedUrl.searchParams.get("department_id") || 0);
|
||||
|
||||
@@ -10,9 +10,13 @@ vi.mock("@/components/session/authenticatedRequest.vue", () => ({
|
||||
|
||||
import {
|
||||
EDGE_GATEWAY_WORKSPACE_CACHE_KEY,
|
||||
getEdgeGatewayDepartmentWorkspace,
|
||||
getEdgeGatewayInstallTokenStatus,
|
||||
getEdgeGatewayModuleConfig,
|
||||
listEdgeGatewayDepartmentWorkspaces,
|
||||
rotateNumberPlateScannerKey,
|
||||
setEdgeGatewayModuleConfig,
|
||||
updateNumberPlateScanner,
|
||||
} from "@/services/edgeGateways.js";
|
||||
|
||||
describe("edge gateway service", () => {
|
||||
@@ -76,6 +80,69 @@ describe("edge gateway service", () => {
|
||||
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", () => {
|
||||
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 tasksSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
|
||||
const settingsSource = readFileSync(
|
||||
join(process.cwd(), "src/features/edgeGateways/EdgeGatewaySettingsPage.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");
|
||||
|
||||
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: "inventory"');
|
||||
expect(managerSource).toContain('id: "tasks"');
|
||||
expect(managerSource).toContain('id: "logs"');
|
||||
expect(managerSource).toContain('id: "statistics"');
|
||||
expect(managerSource).toContain('id: "terminal"');
|
||||
expect(managerSource).toContain('id: "inventory"');
|
||||
expect(managerSource).toContain('id: "settings"');
|
||||
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-uninstall"');
|
||||
expect(settingsSource).toContain('data-testid="gateway-rotate-credentials"');
|
||||
expect(terminalSource).toContain('data-testid="gateway-terminal-connect"');
|
||||
expect(terminalSource).toContain('data-testid="gateway-terminal-send"');
|
||||
expect(manageSource).toContain('data-testid="gateway-rotate-credentials"');
|
||||
expect(managerSource).toContain("EdgeGatewayTerminalPage");
|
||||
expect(managerSource).toContain("createGatewayShellClient");
|
||||
});
|
||||
|
||||
it("maps structured edge gateway errors into UI copy", () => {
|
||||
|
||||
@@ -2,35 +2,57 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const managerSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
||||
const overviewSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"), "utf8");
|
||||
const inventorySource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"), "utf8");
|
||||
const tasksSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayTasksPage.vue"), "utf8");
|
||||
const logsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayLogsPage.vue"), "utf8");
|
||||
const statisticsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayStatisticsPage.vue"), "utf8");
|
||||
const terminalSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewayTerminalPage.vue"), "utf8");
|
||||
const settingsSource = readFileSync(join(root, "src/features/edgeGateways/EdgeGatewaySettingsPage.vue"), "utf8");
|
||||
const routerSource = readFileSync(join(root, "src/router.js"), "utf8");
|
||||
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
|
||||
const overviewSource = readFileSync(
|
||||
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const inventorySource = readFileSync(
|
||||
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"),
|
||||
"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(
|
||||
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"
|
||||
);
|
||||
const departmentGatewaysPageSource = readFileSync(
|
||||
join(root, "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
|
||||
join(process.cwd(), "src/views/dashboards/superUserDashboard/department/DepartmentGatewaysWorkspacePage.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const navigationSource = readFileSync(
|
||||
join(root, "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"),
|
||||
join(process.cwd(), "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"),
|
||||
"utf8"
|
||||
);
|
||||
const configurationNavigationSource = readFileSync(
|
||||
join(root, "src/views/dashboards/superUserDashboard/configuration/SuperUserDashboardConfigurationNavigation.vue"),
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/views/dashboards/superUserDashboard/configuration/SuperUserDashboardConfigurationNavigation.vue"
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
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="gateway-summary-strip"');
|
||||
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-step"');
|
||||
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 EdgeGatewayInventoryPage");
|
||||
expect(managerSource).toContain("import EdgeGatewayTasksPage");
|
||||
expect(managerSource).toContain("import EdgeGatewayLogsPage");
|
||||
expect(managerSource).toContain("import EdgeGatewayStatisticsPage");
|
||||
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-open-full-page"');
|
||||
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-outbox"');
|
||||
@@ -66,20 +80,34 @@ describe("edge gateway workspace contract", () => {
|
||||
expect(logsSource).toContain('data-testid="gateway-logs-page"');
|
||||
expect(statisticsSource).toContain('data-testid="gateway-statistics-page"');
|
||||
expect(terminalSource).toContain('data-testid="gateway-terminal-page"');
|
||||
expect(terminalSource).toContain('data-testid="gateway-terminal-status"');
|
||||
expect(settingsSource).toContain('data-testid="gateway-settings-page"');
|
||||
expect(settingsSource).toContain('data-testid="gateway-rotate-confirmation"');
|
||||
expect(settingsSource).toContain('data-testid="gateway-delete-confirmation"');
|
||||
expect(manageSource).toContain('data-testid="gateway-settings-page"');
|
||||
expect(manageSource).toContain('data-testid="gateway-rotate-confirmation"');
|
||||
expect(manageSource).toContain('data-testid="gateway-delete-confirmation"');
|
||||
});
|
||||
|
||||
it("mounts a module workspace and a safe-subset department workspace", () => {
|
||||
expect(edgeGatewaysPageSource).toContain('data-testid="edge-gateway-module-config"');
|
||||
expect(edgeGatewaysPageSource).toContain("gateway-module-disabled-state");
|
||||
expect(edgeGatewaysPageSource).toContain("import EdgeGatewayFleetLanding");
|
||||
expect(edgeGatewaysPageSource).toContain(':route-driven="true"');
|
||||
expect(edgeGatewaysPageSource).toContain(':allow-destructive="true"');
|
||||
expect(departmentGatewaysPageSource).toContain(':route-driven="false"');
|
||||
expect(departmentGatewaysPageSource).toContain(':allow-destructive="false"');
|
||||
expect(departmentGatewaysPageSource).toContain('@open-gateway-page="openGatewayPage"');
|
||||
expect(departmentGatewaysPageSource).toContain("import EdgeGatewayDepartmentWorkspace");
|
||||
expect(departmentGatewaysPageSource).toContain("Department Hardware Workspace");
|
||||
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", () => {
|
||||
@@ -97,13 +125,9 @@ describe("edge gateway workspace contract", () => {
|
||||
expect(routerSource).toContain("name: 'edgegatewayinventory'");
|
||||
expect(routerSource).toContain("path: '/superuser/configuration/edgegateway/:id/inventory'");
|
||||
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("path: '/superuser/configuration/edgegateway/:id/settings'");
|
||||
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("configure: 'inventory'");
|
||||
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", () => {
|
||||
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("path: '/search/system/record/:entityType/:entityId'");
|
||||
expect(routerSource).toContain("component: SystemSearchRecordPage");
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("weatherapi superuser wiring", () => {
|
||||
|
||||
it("has a dedicated superuser configuration route", () => {
|
||||
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("path: '/superuser/configuration/weatherapi'");
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("workfeed superuser wiring", () => {
|
||||
|
||||
it("has a dedicated superuser configuration route", () => {
|
||||
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("path: '/superuser/configuration/workfeed'");
|
||||
|
||||
+54
-8
@@ -1,9 +1,10 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import VueJsx from '@vitejs/plugin-vue-jsx'
|
||||
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 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
|
||||
const version = process.env.npm_package_version || '0.0.0'
|
||||
@@ -101,11 +137,12 @@ export default defineConfig(({ mode }) => {
|
||||
base,
|
||||
plugins: [
|
||||
patchBuefyCssMediaQuery(),
|
||||
stripBrokenVueToastNotificationSourceMap(),
|
||||
vue(),
|
||||
VueJsx(),
|
||||
!isProd && !isPlaywrightRuntime && vueDevTools(),
|
||||
enableSingleFile && viteSingleFile(),
|
||||
VitePWA({
|
||||
VitePWA && VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: isProd ? 'auto' : false,
|
||||
strategies: 'generateSW',
|
||||
@@ -194,8 +231,11 @@ export default defineConfig(({ mode }) => {
|
||||
})
|
||||
].filter(Boolean),
|
||||
resolve: {
|
||||
preserveSymlinks: true,
|
||||
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: {
|
||||
@@ -204,6 +244,7 @@ export default defineConfig(({ mode }) => {
|
||||
'import.meta.env.VITE_COMMIT_HASH': JSON.stringify(commit),
|
||||
// Tip: import.meta.env.DEV/PROD are available at runtime
|
||||
'import.meta.env.VITE_IS_DEV': JSON.stringify(!isProd),
|
||||
'import.meta.env.VITE_IS_PLAYWRIGHT': JSON.stringify(isPlaywrightRuntime),
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
@@ -219,11 +260,16 @@ export default defineConfig(({ mode }) => {
|
||||
ignored: ['**/output/playwright/**']
|
||||
}
|
||||
},
|
||||
optimizeDeps: isPlaywrightRuntime
|
||||
optimizeDeps: shouldDisableDepOptimizer
|
||||
? {
|
||||
noDiscovery: true
|
||||
noDiscovery: true,
|
||||
include: []
|
||||
}
|
||||
: undefined
|
||||
: isPlaywrightRuntime
|
||||
? {
|
||||
entries: ['index.html']
|
||||
}
|
||||
: undefined
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user