Add self-serve sessions management with filtering, force-stop functionality, and enhanced E2E test coverage. Extend edge gateway module configuration tests.
This commit is contained in:
@@ -323,6 +323,7 @@ const items = computed<NavigationItemProps[]>(() => [
|
||||
{ label: t('superuser.nav.department_relays'), to: '/superuser/department/relays'},
|
||||
{ label: t('superuser.nav.scanners'), to: '/superuser/scanners' },
|
||||
{ label: t('superuser.nav.selfserve'), to: '/superuser/selfserve' },
|
||||
{ label: 'Self-serve sessions', to: '/superuser/selfserve/sessions' },
|
||||
]},
|
||||
// Configurations
|
||||
{ label: t('superuser.nav.configuration'), type: 'category', children: [
|
||||
|
||||
@@ -55,6 +55,22 @@ export const Config = {
|
||||
get: async () => Config.get("default_update_window"),
|
||||
set: async (value) => Config.set("default_update_window", value),
|
||||
},
|
||||
broker_url: {
|
||||
get: async () => Config.get("broker_url"),
|
||||
set: async (value) => Config.set("broker_url", value),
|
||||
},
|
||||
public_broker_url: {
|
||||
get: async () => Config.get("public_broker_url"),
|
||||
set: async (value) => Config.set("public_broker_url", value),
|
||||
},
|
||||
broker_auth_mode: {
|
||||
get: async () => Config.get("broker_auth_mode"),
|
||||
set: async (value) => Config.set("broker_auth_mode", value),
|
||||
},
|
||||
broker_shared_secret: {
|
||||
get: async () => Config.get("broker_shared_secret"),
|
||||
set: async (value) => Config.set("broker_shared_secret", value),
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
relayLogs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
streamStatus: {
|
||||
type: String,
|
||||
default: "idle",
|
||||
@@ -23,6 +27,73 @@ const props = defineProps({
|
||||
const levelFilter = ref("ALL");
|
||||
const typeFilter = ref("ALL");
|
||||
|
||||
const entryContext = (entry = {}) => {
|
||||
const context = entry?.context || entry?.entry?.context || {};
|
||||
return context && typeof context === "object" && !Array.isArray(context) ? context : {};
|
||||
};
|
||||
|
||||
const relayEntries = computed(() => {
|
||||
if (props.relayLogs.length) {
|
||||
return props.relayLogs;
|
||||
}
|
||||
|
||||
return props.timeline
|
||||
.filter((entry) => String(entry?.type || "").toLowerCase() === "relay")
|
||||
.map((entry) => entry?.entry || entry)
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const relayModuleLabel = (entry = {}) => entryContext(entry).module_responsible || entryContext(entry).module || "edge_gateway";
|
||||
|
||||
const relayHandlerLabel = (entry = {}) => {
|
||||
const context = entryContext(entry);
|
||||
const handler = String(context.handler || context.execution_path || "local").toLowerCase();
|
||||
const channel = context.delivery_channel || context.execution?.channel || "";
|
||||
return `${handler}${channel ? ` / ${channel}` : ""}`;
|
||||
};
|
||||
|
||||
const relayReasonLabel = (entry = {}) => entryContext(entry).reason || "Relay dispatch";
|
||||
|
||||
const relayActorLabel = (entry = {}) => {
|
||||
const context = entryContext(entry);
|
||||
const actor = context.actor && typeof context.actor === "object" ? context.actor : {};
|
||||
const associated = context.associated && typeof context.associated === "object" ? context.associated : {};
|
||||
const admin = associated.admin_user_id || actor.admin_user_id;
|
||||
const customer = associated.customer_number || actor.customer_number;
|
||||
const customerUser = associated.customer_user_id || actor.customer_user_id;
|
||||
const subuser = associated.subuser_id || actor.subuser_id;
|
||||
const parts = [];
|
||||
|
||||
if (admin) parts.push(`Admin #${admin}`);
|
||||
if (customer) parts.push(`Customer ${customer}`);
|
||||
else if (customerUser) parts.push(`Customer user #${customerUser}`);
|
||||
if (subuser) parts.push(`Subuser #${subuser}`);
|
||||
|
||||
return parts.join(" / ") || "No actor";
|
||||
};
|
||||
|
||||
const relaySignalLabel = (entry = {}) => {
|
||||
const context = entryContext(entry);
|
||||
const signal = context.signal && typeof context.signal === "object" ? context.signal : {};
|
||||
const action = String(context.action || signal.command_type || "RELAY").toUpperCase();
|
||||
const relayId = context.relay_id || signal.relay_id || signal.request?.relayId || signal.request?.id || "unknown";
|
||||
const targetOn = Object.prototype.hasOwnProperty.call(context, "target_on") ? context.target_on : signal.request?.on;
|
||||
const target = typeof targetOn === "boolean" ? (targetOn ? "ON" : "OFF") : "";
|
||||
return [action, relayId, target].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
const relayResponseLabel = (entry = {}) => {
|
||||
const response = entryContext(entry).response || {};
|
||||
const parts = [];
|
||||
if (Object.prototype.hasOwnProperty.call(response, "online")) {
|
||||
parts.push(`online ${response.online ? "yes" : "no"}`);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(response, "on")) {
|
||||
parts.push(`state ${response.on ? "ON" : "OFF"}`);
|
||||
}
|
||||
return parts.join(" / ") || "No response payload";
|
||||
};
|
||||
|
||||
const filteredTimeline = computed(() =>
|
||||
props.timeline.filter((entry) => {
|
||||
if (levelFilter.value !== "ALL" && String(entry.level || "").toUpperCase() !== levelFilter.value) {
|
||||
@@ -51,6 +122,7 @@ const filteredTimeline = computed(() =>
|
||||
<option value="ALL">All types</option>
|
||||
<option value="audit">Audit</option>
|
||||
<option value="log">Agent logs</option>
|
||||
<option value="relay">Relay</option>
|
||||
<option value="operation_event">Task events</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -83,6 +155,40 @@ const filteredTimeline = computed(() =>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="edge-gateway-logs__panel">
|
||||
<p class="edge-gateway-logs__eyebrow">Relay dispatch</p>
|
||||
<h3>Signals and responses</h3>
|
||||
<ul class="edge-gateway-logs__timeline" data-testid="gateway-relay-logs">
|
||||
<li v-for="entry in relayEntries" :key="`relay-${entry.id || entry.created_at || entry.message}`">
|
||||
<div>
|
||||
<strong>{{ relayHandlerLabel(entry) }}</strong>
|
||||
<small>{{ relayModuleLabel(entry) }}</small>
|
||||
</div>
|
||||
<p>{{ entry.message || relaySignalLabel(entry) }}</p>
|
||||
<dl class="edge-gateway-logs__relay-details">
|
||||
<div>
|
||||
<dt>Signal</dt>
|
||||
<dd>{{ relaySignalLabel(entry) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Response</dt>
|
||||
<dd>{{ relayResponseLabel(entry) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Actor</dt>
|
||||
<dd>{{ relayActorLabel(entry) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reason</dt>
|
||||
<dd>{{ relayReasonLabel(entry) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<small>{{ entry.created_at || "No timestamp" }}</small>
|
||||
</li>
|
||||
<li v-if="!relayEntries.length">No relay dispatch entries recorded.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="edge-gateway-logs__panel">
|
||||
<p class="edge-gateway-logs__eyebrow">Terminal sessions</p>
|
||||
<h3>Recent shell activity</h3>
|
||||
@@ -158,4 +264,33 @@ const filteredTimeline = computed(() =>
|
||||
margin: 0.35rem 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.edge-gateway-logs__relay-details {
|
||||
margin: 0.75rem 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.edge-gateway-logs__relay-details div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(5rem, 0.35fr) 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.edge-gateway-logs__relay-details dt,
|
||||
.edge-gateway-logs__relay-details dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.edge-gateway-logs__relay-details dt {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.edge-gateway-logs__relay-details dd {
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -573,7 +573,7 @@ const buildLocalGatewayTimeline = (gateway) => {
|
||||
entry,
|
||||
}));
|
||||
const logEntries = (Array.isArray(gateway.log_entries) ? gateway.log_entries : []).map((entry) => ({
|
||||
type: "log",
|
||||
type: String(entry?.stream || "").toLowerCase() === "relay" ? "relay" : "log",
|
||||
level: entry?.level || "INFO",
|
||||
message: entry?.message || "",
|
||||
created_at: entry?.created_at || null,
|
||||
@@ -695,6 +695,18 @@ const logTimeline = computed(() => {
|
||||
return buildLocalGatewayTimeline(selectedGatewayView.value);
|
||||
});
|
||||
const logShellSessions = computed(() => (Array.isArray(logsSnapshot.value?.shell_sessions) ? logsSnapshot.value.shell_sessions : []));
|
||||
const logRelayLogs = computed(() => {
|
||||
if (Array.isArray(logsSnapshot.value?.relay_logs)) {
|
||||
return logsSnapshot.value.relay_logs;
|
||||
}
|
||||
|
||||
const fallbackEntries = Array.isArray(logsSnapshot.value?.log_entries)
|
||||
? logsSnapshot.value.log_entries
|
||||
: selectedGatewayView.value?.log_entries;
|
||||
return (Array.isArray(fallbackEntries) ? fallbackEntries : []).filter(
|
||||
(entry) => String(entry?.stream || "").toLowerCase() === "relay"
|
||||
);
|
||||
});
|
||||
const statisticsPageState = computed(() => {
|
||||
if (statisticsSnapshot.value && typeof statisticsSnapshot.value === "object" && Object.keys(statisticsSnapshot.value).length) {
|
||||
return statisticsSnapshot.value;
|
||||
@@ -2528,6 +2540,7 @@ onUnmounted(() => {
|
||||
<EdgeGatewayLogsPage
|
||||
v-else-if="currentView === 'logs'"
|
||||
:timeline="logTimeline"
|
||||
:relay-logs="logRelayLogs"
|
||||
:shell-sessions="logShellSessions"
|
||||
:stream-status="gatewayStreamStatus"
|
||||
:stream-error="gatewayStreamError"
|
||||
|
||||
@@ -54,6 +54,7 @@ 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 SelfServeSessions = lazyView('@/views/dashboards/superUserDashboard/SelfServeSessions.vue');
|
||||
const EdgeGatewaysWorkspacePage = lazyView('@/views/dashboards/superUserDashboard/EdgeGatewaysWorkspacePage.vue');
|
||||
const Invoicing = lazyView('@/views/dashboards/superUserDashboard/Invoicing.vue');
|
||||
|
||||
@@ -644,6 +645,12 @@ export const router = createRouter({
|
||||
component: DepartmentSelfServe,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'selfservesessions',
|
||||
path: '/superuser/selfserve/sessions',
|
||||
component: SelfServeSessions,
|
||||
meta: { middleware: superUserMiddleware }
|
||||
},
|
||||
{
|
||||
name: 'departmentrelays',
|
||||
path: '/superuser/department/relays',
|
||||
|
||||
@@ -636,13 +636,18 @@ export const updateNumberPlateScanner = async (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) => {
|
||||
export const getEdgeGatewayModuleConfig = async (variable = null) => {
|
||||
const endpoint = variable
|
||||
? `${EDGE_GATEWAY_CONFIG_BASE}?variable=${encodeURIComponent(String(variable))}`
|
||||
: EDGE_GATEWAY_CONFIG_BASE;
|
||||
|
||||
return authenticatedRequest(endpoint, "GET", {}).then((response) => {
|
||||
const entries = normalizeEntries(unwrapEdgeGatewayResponse(response, []));
|
||||
response.data.data = entries;
|
||||
response.data.config = toConfigPayload(entries);
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
export const setEdgeGatewayModuleConfig = async (payload) =>
|
||||
authenticatedRequest(EDGE_GATEWAY_CONFIG_BASE, "POST", payload);
|
||||
|
||||
@@ -4,6 +4,7 @@ import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrap
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import DepartmentSelfServePagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentSelfServePagination.vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import SelfServeSuperUserTabs from "@/views/dashboards/superUserDashboard/selfserve/SelfServeSuperUserTabs.vue";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -15,6 +16,7 @@ const openFleetLanding = async () => {
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<SuperUserDashboardNavigation />
|
||||
<SelfServeSuperUserTabs />
|
||||
<section class="hardware-legacy-notice" data-testid="selfserve-workspace-link">
|
||||
<div>
|
||||
<strong>Self-serve readiness is now part of the department hardware workspace.</strong>
|
||||
|
||||
@@ -40,6 +40,10 @@ const moduleConfigMap = ref({
|
||||
enabled: true,
|
||||
default_release_channel: "stable",
|
||||
default_update_window: "02:00-04:00",
|
||||
broker_url: "http://edge-broker:4300",
|
||||
public_broker_url: "",
|
||||
broker_auth_mode: "manager",
|
||||
broker_shared_secret: "",
|
||||
});
|
||||
const moduleState = reactive({
|
||||
loading: true,
|
||||
@@ -73,6 +77,10 @@ const loadModuleConfig = async () => {
|
||||
enabled: response?.data?.config?.enabled !== false,
|
||||
default_release_channel: response?.data?.config?.default_release_channel || "stable",
|
||||
default_update_window: response?.data?.config?.default_update_window || "02:00-04:00",
|
||||
broker_url: response?.data?.config?.broker_url || "http://edge-broker:4300",
|
||||
public_broker_url: response?.data?.config?.public_broker_url || "",
|
||||
broker_auth_mode: response?.data?.config?.broker_auth_mode || "manager",
|
||||
broker_shared_secret: response?.data?.config?.broker_shared_secret || "",
|
||||
};
|
||||
} catch (error) {
|
||||
moduleState.error = normalizeEdgeGatewayError(error);
|
||||
@@ -91,6 +99,10 @@ const saveModuleConfig = async () => {
|
||||
enabled: Boolean(moduleConfigMap.value.enabled),
|
||||
default_release_channel: moduleConfigMap.value.default_release_channel || "stable",
|
||||
default_update_window: moduleConfigMap.value.default_update_window || "02:00-04:00",
|
||||
broker_url: moduleConfigMap.value.broker_url || "",
|
||||
public_broker_url: moduleConfigMap.value.public_broker_url || "",
|
||||
broker_auth_mode: moduleConfigMap.value.broker_auth_mode || "manager",
|
||||
broker_shared_secret: moduleConfigMap.value.broker_shared_secret || "",
|
||||
});
|
||||
await loadModuleConfig();
|
||||
} catch (error) {
|
||||
@@ -164,6 +176,53 @@ onMounted(async () => {
|
||||
placeholder="02:00-04:00"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="edge-gateway-module-page__section-title">
|
||||
<h3>Broker settings</h3>
|
||||
<p>Connection values used by the PHP manager when it prepares browser and gateway broker traffic.</p>
|
||||
</div>
|
||||
|
||||
<label class="edge-gateway-module-page__field">
|
||||
<span>Internal broker URL</span>
|
||||
<input
|
||||
v-model="moduleConfigMap.broker_url"
|
||||
class="input"
|
||||
data-testid="gateway-module-broker-url"
|
||||
placeholder="http://edge-broker:4300"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="edge-gateway-module-page__field">
|
||||
<span>Public broker URL</span>
|
||||
<input
|
||||
v-model="moduleConfigMap.public_broker_url"
|
||||
class="input"
|
||||
data-testid="gateway-module-public-broker-url"
|
||||
placeholder="https://api.truckwash.io:4433/edge-broker"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="edge-gateway-module-page__field">
|
||||
<span>Broker auth mode</span>
|
||||
<div class="select is-fullwidth">
|
||||
<select v-model="moduleConfigMap.broker_auth_mode" data-testid="gateway-module-broker-auth-mode">
|
||||
<option value="manager">manager</option>
|
||||
<option value="stub">stub</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="edge-gateway-module-page__field">
|
||||
<span>Broker shared secret</span>
|
||||
<input
|
||||
v-model="moduleConfigMap.broker_shared_secret"
|
||||
autocomplete="off"
|
||||
class="input"
|
||||
data-testid="gateway-module-broker-shared-secret"
|
||||
placeholder="Shared secret"
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="edge-gateway-module-page__actions">
|
||||
@@ -240,6 +299,22 @@ onMounted(async () => {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.edge-gateway-module-page__section-title {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.edge-gateway-module-page__section-title h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
color: #0f172a;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.edge-gateway-module-page__section-title p {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.edge-gateway-module-page__actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,815 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import SelfServeSuperUserTabs from "@/views/dashboards/superUserDashboard/selfserve/SelfServeSuperUserTabs.vue";
|
||||
import {
|
||||
forceStopLane,
|
||||
fetchInProgressDetails,
|
||||
sessions,
|
||||
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
|
||||
|
||||
type SelfServeSessionRow = {
|
||||
id: number;
|
||||
lane_id: number;
|
||||
department_id: number;
|
||||
reg?: string | null;
|
||||
customer_number?: number | null;
|
||||
status?: string | null;
|
||||
allowed?: boolean;
|
||||
order_id?: number | null;
|
||||
completed_at?: string | null;
|
||||
wash_started_at?: string | null;
|
||||
machine_start_triggered_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
elapsed_minutes?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type SessionDetail = {
|
||||
session: SelfServeSessionRow;
|
||||
lane?: Record<string, unknown> | null;
|
||||
machine_type?: Record<string, unknown> | null;
|
||||
questions?: Array<Record<string, unknown>>;
|
||||
tasks?: Array<Record<string, unknown>>;
|
||||
events?: Array<Record<string, unknown>>;
|
||||
evaluation_trace?: unknown;
|
||||
config_version_id?: number | string | null;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
"PENDING_QUESTIONS",
|
||||
"READY_FOR_MACHINE_START",
|
||||
"MACHINE_NOT_ALLOWED",
|
||||
"MACHINE_RELAY_ENABLED",
|
||||
"MACHINE_STARTED",
|
||||
"COMPLETED",
|
||||
"FORCE_STOPPED",
|
||||
];
|
||||
|
||||
const filters = reactive({
|
||||
search: "",
|
||||
departmentId: "",
|
||||
laneId: "",
|
||||
status: "",
|
||||
openOnly: true,
|
||||
order: "id:DESC",
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 25,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const rows = ref<SelfServeSessionRow[]>([]);
|
||||
const listLoading = ref(false);
|
||||
const listError = ref<string | null>(null);
|
||||
const selectedSessionId = ref<number | null>(null);
|
||||
const selectedDetail = ref<SessionDetail | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref<string | null>(null);
|
||||
|
||||
const forceStop = reactive({
|
||||
open: false,
|
||||
submitting: false,
|
||||
bill: false,
|
||||
reason: "",
|
||||
error: null as string | null,
|
||||
});
|
||||
|
||||
const forceStopSession = ref<SelfServeSessionRow | null>(null);
|
||||
|
||||
const extractEnvelope = (response: any): any => response?.data ?? response ?? {};
|
||||
|
||||
const extractPayload = (response: any): any => {
|
||||
const envelope = extractEnvelope(response);
|
||||
if (envelope && typeof envelope === "object" && Object.prototype.hasOwnProperty.call(envelope, "data")) {
|
||||
return envelope.data;
|
||||
}
|
||||
return envelope;
|
||||
};
|
||||
|
||||
const extractPagination = (response: any): Record<string, unknown> => {
|
||||
const envelope = extractEnvelope(response);
|
||||
return envelope?.meta?.pagination || {};
|
||||
};
|
||||
|
||||
const parseErrorMessage = (error: unknown): string => {
|
||||
try {
|
||||
const parsed = SessionUser?.functions?.parseErrorMessage?.(error as any);
|
||||
if (parsed !== undefined && parsed !== null && String(parsed).trim() !== "") {
|
||||
return String(parsed);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse self-serve session error", parseError);
|
||||
}
|
||||
return error instanceof Error && error.message ? error.message : "Request failed";
|
||||
};
|
||||
|
||||
const coerceInteger = (value: unknown): number | null => {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const buildFilterString = (): string => {
|
||||
const parts: string[] = [];
|
||||
const departmentId = coerceInteger(filters.departmentId);
|
||||
const laneId = coerceInteger(filters.laneId);
|
||||
|
||||
if (departmentId) {
|
||||
parts.push(`department_id:${departmentId}`);
|
||||
}
|
||||
if (laneId) {
|
||||
parts.push(`lane_id:${laneId}`);
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(`status:${filters.status}`);
|
||||
}
|
||||
|
||||
return parts.join(",");
|
||||
};
|
||||
|
||||
const requestParams = computed(() => ({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
search: filters.search,
|
||||
filters: buildFilterString(),
|
||||
order: filters.order || "id:DESC",
|
||||
open_only: filters.openOnly,
|
||||
}));
|
||||
|
||||
const normalizeRows = (payload: unknown): SelfServeSessionRow[] => {
|
||||
if (!Array.isArray(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload
|
||||
.map((row) => row && typeof row === "object" ? row as SelfServeSessionRow : null)
|
||||
.filter((row): row is SelfServeSessionRow => row !== null && coerceInteger(row.id) !== null);
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
listLoading.value = true;
|
||||
listError.value = null;
|
||||
try {
|
||||
const response = await sessions.list(requestParams.value);
|
||||
rows.value = normalizeRows(extractPayload(response));
|
||||
const meta = extractPagination(response);
|
||||
pagination.total = Number(meta.total ?? rows.value.length) || rows.value.length;
|
||||
pagination.page = Number(meta.current_page ?? pagination.page) || pagination.page;
|
||||
pagination.limit = Number(meta.per_page ?? pagination.limit) || pagination.limit;
|
||||
} catch (error) {
|
||||
listError.value = parseErrorMessage(error);
|
||||
} finally {
|
||||
listLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessionDetail = async (sessionId: number) => {
|
||||
detailLoading.value = true;
|
||||
detailError.value = null;
|
||||
selectedSessionId.value = sessionId;
|
||||
try {
|
||||
const response = await sessions.detail(sessionId);
|
||||
selectedDetail.value = extractPayload(response) as SessionDetail;
|
||||
} catch (error) {
|
||||
selectedDetail.value = null;
|
||||
detailError.value = parseErrorMessage(error);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedSession = computed<SelfServeSessionRow | null>(() => (
|
||||
selectedDetail.value?.session
|
||||
|| rows.value.find((row) => Number(row.id) === Number(selectedSessionId.value))
|
||||
|| null
|
||||
));
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil((pagination.total || rows.value.length) / pagination.limit)));
|
||||
|
||||
const isOpenSession = (session: SelfServeSessionRow | null): boolean => {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
const status = String(session.status || "").toUpperCase();
|
||||
return !session.completed_at && status !== "COMPLETED" && status !== "FORCE_STOPPED";
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string | null): string => {
|
||||
if (!value) {
|
||||
return "N/A";
|
||||
}
|
||||
const parsed = new Date(String(value).replace(" ", "T"));
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const formatDuration = (session: SelfServeSessionRow): string => {
|
||||
if (typeof session.elapsed_minutes === "number") {
|
||||
return `${session.elapsed_minutes} min`;
|
||||
}
|
||||
|
||||
const startedAt = session.wash_started_at || session.machine_start_triggered_at || session.created_at;
|
||||
if (!startedAt) {
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
const start = new Date(String(startedAt).replace(" ", "T"));
|
||||
const end = session.completed_at ? new Date(String(session.completed_at).replace(" ", "T")) : new Date();
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) {
|
||||
return "N/A";
|
||||
}
|
||||
return `${Math.floor((end.getTime() - start.getTime()) / 60000)} min`;
|
||||
};
|
||||
|
||||
const rowTitle = (session: SelfServeSessionRow): string => `#${session.id}`;
|
||||
|
||||
const orderState = (session: SelfServeSessionRow): string => {
|
||||
if (session.order_id) {
|
||||
return `Order ${session.order_id}`;
|
||||
}
|
||||
return "No order";
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
pagination.page = 1;
|
||||
void fetchSessions();
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.search = "";
|
||||
filters.departmentId = "";
|
||||
filters.laneId = "";
|
||||
filters.status = "";
|
||||
filters.openOnly = true;
|
||||
filters.order = "id:DESC";
|
||||
applyFilters();
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
pagination.page = Math.min(Math.max(1, page), totalPages.value);
|
||||
void fetchSessions();
|
||||
};
|
||||
|
||||
const openForceStopModal = (session: SelfServeSessionRow | null = selectedSession.value) => {
|
||||
if (!isOpenSession(session)) {
|
||||
return;
|
||||
}
|
||||
forceStopSession.value = session;
|
||||
forceStop.open = true;
|
||||
forceStop.bill = false;
|
||||
forceStop.reason = "";
|
||||
forceStop.error = null;
|
||||
};
|
||||
|
||||
const closeForceStopModal = () => {
|
||||
if (forceStop.submitting) {
|
||||
return;
|
||||
}
|
||||
forceStop.open = false;
|
||||
forceStopSession.value = null;
|
||||
};
|
||||
|
||||
const submitForceStop = async () => {
|
||||
const session = forceStopSession.value;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
forceStop.submitting = true;
|
||||
forceStop.error = null;
|
||||
try {
|
||||
await forceStopLane({
|
||||
laneId: Number(session.lane_id),
|
||||
sessionId: Number(session.id),
|
||||
bill: forceStop.bill,
|
||||
reason: forceStop.reason,
|
||||
});
|
||||
await fetchInProgressDetails(Number(session.lane_id)).catch(() => null);
|
||||
await fetchSessions();
|
||||
await fetchSessionDetail(Number(session.id));
|
||||
forceStop.open = false;
|
||||
forceStopSession.value = null;
|
||||
} catch (error) {
|
||||
forceStop.error = parseErrorMessage(error);
|
||||
} finally {
|
||||
forceStop.submitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const stringifyValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "N/A";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void fetchSessions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<SuperUserDashboardNavigation />
|
||||
<SelfServeSuperUserTabs />
|
||||
|
||||
<main class="selfserve-sessions" data-testid="self-serve-sessions-page">
|
||||
<header class="selfserve-sessions__header">
|
||||
<div>
|
||||
<h1>Selvvask Sessions</h1>
|
||||
<p>Review active and historical self-serve wash sessions.</p>
|
||||
</div>
|
||||
<button class="button is-light" type="button" :disabled="listLoading" @click="fetchSessions">
|
||||
Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="selfserve-sessions__filters" data-testid="self-serve-sessions-filters" @submit.prevent="applyFilters">
|
||||
<label>
|
||||
<span>Search</span>
|
||||
<input
|
||||
v-model="filters.search"
|
||||
class="input"
|
||||
data-testid="self-serve-sessions-search"
|
||||
placeholder="Reg, customer, or session id"
|
||||
type="search"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Department</span>
|
||||
<input v-model="filters.departmentId" class="input" data-testid="self-serve-sessions-department" min="1" type="number" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Lane</span>
|
||||
<input v-model="filters.laneId" class="input" data-testid="self-serve-sessions-lane" min="1" type="number" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select v-model="filters.status" class="select-control" data-testid="self-serve-sessions-status">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="status in STATUS_OPTIONS" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Sort</span>
|
||||
<select v-model="filters.order" class="select-control" data-testid="self-serve-sessions-sort">
|
||||
<option value="id:DESC">Newest first</option>
|
||||
<option value="id:ASC">Oldest first</option>
|
||||
<option value="created_at:DESC">Created latest</option>
|
||||
<option value="completed_at:DESC">Completed latest</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input v-model="filters.openOnly" data-testid="self-serve-sessions-open-only" type="checkbox" />
|
||||
<span>Open only</span>
|
||||
</label>
|
||||
<div class="filter-actions">
|
||||
<button class="button is-dark" type="submit">Apply</button>
|
||||
<button class="button is-light" type="button" @click="resetFilters">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p v-if="listError" class="session-error" data-testid="self-serve-sessions-error">{{ listError }}</p>
|
||||
|
||||
<section class="sessions-layout">
|
||||
<div class="sessions-table-wrap">
|
||||
<table class="sessions-table" data-testid="self-serve-sessions-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session</th>
|
||||
<th>Department / lane</th>
|
||||
<th>Registration</th>
|
||||
<th>Customer</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Completed</th>
|
||||
<th>Duration</th>
|
||||
<th>Billing</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="listLoading">
|
||||
<td colspan="10">Loading sessions...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0">
|
||||
<td colspan="10">No sessions found.</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr
|
||||
v-for="session in rows"
|
||||
:key="session.id"
|
||||
:class="{ 'is-selected': selectedSessionId === session.id }"
|
||||
:data-testid="`self-serve-session-row-${session.id}`"
|
||||
@click="fetchSessionDetail(Number(session.id))"
|
||||
>
|
||||
<td>{{ rowTitle(session) }}</td>
|
||||
<td>{{ session.department_id || "N/A" }} / {{ session.lane_id || "N/A" }}</td>
|
||||
<td>{{ session.reg || "N/A" }}</td>
|
||||
<td>{{ session.customer_number || "N/A" }}</td>
|
||||
<td><span class="status-pill">{{ session.status || "N/A" }}</span></td>
|
||||
<td>{{ formatDateTime(session.wash_started_at || session.machine_start_triggered_at || session.created_at) }}</td>
|
||||
<td>{{ formatDateTime(session.completed_at) }}</td>
|
||||
<td>{{ formatDuration(session) }}</td>
|
||||
<td>
|
||||
<span>{{ session.allowed ? "Allowed" : "Not allowed" }}</span>
|
||||
<span class="muted">{{ orderState(session) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="button is-small is-light" type="button" @click.stop="fetchSessionDetail(Number(session.id))">
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
v-if="isOpenSession(session)"
|
||||
class="button is-small is-danger is-light"
|
||||
type="button"
|
||||
:data-testid="`self-serve-force-stop-button-${session.id}`"
|
||||
@click.stop="openForceStopModal(session)"
|
||||
>
|
||||
Force stop
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<footer class="sessions-pagination">
|
||||
<button class="button is-small" type="button" :disabled="pagination.page <= 1" @click="goToPage(pagination.page - 1)">Previous</button>
|
||||
<span>Page {{ pagination.page }} of {{ totalPages }}</span>
|
||||
<button class="button is-small" type="button" :disabled="pagination.page >= totalPages" @click="goToPage(pagination.page + 1)">Next</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<aside class="session-detail" data-testid="self-serve-session-detail">
|
||||
<div v-if="detailLoading">Loading detail...</div>
|
||||
<p v-else-if="detailError" class="session-error">{{ detailError }}</p>
|
||||
<div v-else-if="selectedDetail">
|
||||
<header class="session-detail__header">
|
||||
<div>
|
||||
<h2>Session #{{ selectedDetail.session.id }}</h2>
|
||||
<p>Lane {{ selectedDetail.session.lane_id }} - {{ selectedDetail.session.status }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="isOpenSession(selectedDetail.session)"
|
||||
class="button is-danger"
|
||||
data-testid="self-serve-session-detail-force-stop"
|
||||
type="button"
|
||||
@click="openForceStopModal(selectedDetail.session)"
|
||||
>
|
||||
Force stop
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>Customer and vehicle</h3>
|
||||
<dl>
|
||||
<div><dt>Customer</dt><dd>{{ selectedDetail.session.customer_number || "N/A" }}</dd></div>
|
||||
<div><dt>Registration</dt><dd>{{ selectedDetail.session.reg || "N/A" }}</dd></div>
|
||||
<div><dt>Vehicle type</dt><dd>{{ selectedDetail.session.vehicle_type_id || "N/A" }}</dd></div>
|
||||
<div><dt>Order</dt><dd>{{ orderState(selectedDetail.session) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>Questions</h3>
|
||||
<ul v-if="selectedDetail.questions?.length">
|
||||
<li v-for="question in selectedDetail.questions" :key="String(question.question_id || question.id)">
|
||||
{{ question.question || question.question_text }}:
|
||||
<strong>{{ stringifyValue(question.answer) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">No questions recorded.</p>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>Tasks and services</h3>
|
||||
<ul v-if="selectedDetail.tasks?.length">
|
||||
<li v-for="task in selectedDetail.tasks" :key="String(task.task_id || task.id)">
|
||||
<strong>{{ task.task || task.task_text }}</strong>
|
||||
<span class="muted">{{ stringifyValue(task.services) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">No tasks recorded.</p>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>Events</h3>
|
||||
<ol v-if="selectedDetail.events?.length">
|
||||
<li v-for="event in selectedDetail.events" :key="String(event.id)">
|
||||
<strong>{{ event.type }}</strong>
|
||||
<span class="muted">{{ formatDateTime(String(event.created_at || "")) }}</span>
|
||||
<pre v-if="event.payload">{{ stringifyValue(event.payload) }}</pre>
|
||||
</li>
|
||||
</ol>
|
||||
<p v-else class="muted">No events recorded.</p>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3>Metadata and evaluation</h3>
|
||||
<pre>{{ stringifyValue({ metadata: selectedDetail.session.metadata || {}, evaluation_trace: selectedDetail.evaluation_trace || null, config_version_id: selectedDetail.config_version_id || null }) }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else class="muted">Select a session to inspect details.</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<div v-if="forceStop.open" class="modal-backdrop" data-testid="self-serve-force-stop-modal">
|
||||
<section class="force-stop-modal" role="dialog" aria-modal="true" aria-labelledby="force-stop-title">
|
||||
<header>
|
||||
<h2 id="force-stop-title">Force stop session #{{ forceStopSession?.id }}</h2>
|
||||
</header>
|
||||
<p>
|
||||
Force stop clears app and lane runtime state with reset behavior only. It does not signal relays or gates.
|
||||
</p>
|
||||
<label class="checkbox-row force-stop-toggle">
|
||||
<input v-model="forceStop.bill" data-testid="self-serve-force-stop-bill" type="checkbox" />
|
||||
<span>Bill elapsed minutes</span>
|
||||
</label>
|
||||
<p v-if="forceStop.bill" class="session-warning" data-testid="self-serve-force-stop-bill-copy">
|
||||
Billing is elapsed-minute only. Vehicle-type products are not added.
|
||||
</p>
|
||||
<label>
|
||||
<span>Reason</span>
|
||||
<textarea v-model="forceStop.reason" class="textarea" data-testid="self-serve-force-stop-reason" rows="3"></textarea>
|
||||
</label>
|
||||
<p v-if="forceStop.error" class="session-error" data-testid="self-serve-force-stop-error">{{ forceStop.error }}</p>
|
||||
<footer>
|
||||
<button class="button is-light" type="button" :disabled="forceStop.submitting" @click="closeForceStopModal">Cancel</button>
|
||||
<button
|
||||
class="button is-danger"
|
||||
data-testid="self-serve-force-stop-submit"
|
||||
type="button"
|
||||
:disabled="forceStop.submitting"
|
||||
@click="submitForceStop"
|
||||
>
|
||||
{{ forceStop.submitting ? "Stopping..." : "Force stop" }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selfserve-sessions {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.selfserve-sessions__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.selfserve-sessions__header h1,
|
||||
.session-detail h2,
|
||||
.force-stop-modal h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.selfserve-sessions__header p,
|
||||
.session-detail__header p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.selfserve-sessions__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 2fr) repeat(4, minmax(8rem, 1fr)) auto auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
padding: 0.9rem 0;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.selfserve-sessions__filters label,
|
||||
.force-stop-modal label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.select-control,
|
||||
.textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
min-height: 2.45rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sessions-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.8fr) minmax(20rem, 0.8fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sessions-table-wrap,
|
||||
.session-detail {
|
||||
border: 1px solid #dbe4ea;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sessions-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.sessions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 64rem;
|
||||
}
|
||||
|
||||
.sessions-table th,
|
||||
.sessions-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.sessions-table th {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.sessions-table tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sessions-table tr:hover,
|
||||
.sessions-table tr.is-selected {
|
||||
background: #f1f5ff;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.muted {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sessions-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.session-detail {
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.session-detail__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-section h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.detail-section dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-section dt {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-section dd {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.session-error,
|
||||
.session-warning {
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.session-error {
|
||||
color: #991b1b;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.session-warning {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.force-stop-modal {
|
||||
width: min(34rem, 100%);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.force-stop-modal footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.force-stop-toggle {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.selfserve-sessions__filters,
|
||||
.sessions-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-detail {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const tabs = [
|
||||
{ label: "Lanes", to: "/superuser/selfserve", exact: true },
|
||||
{ label: "Sessions", to: "/superuser/selfserve/sessions", exact: false },
|
||||
];
|
||||
|
||||
const isActive = (tab: { to: string; exact: boolean }): boolean => (
|
||||
tab.exact ? route.path === tab.to : route.path.startsWith(tab.to)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="selfserve-tabs" aria-label="Self-serve pages">
|
||||
<RouterLink
|
||||
v-for="tab in tabs"
|
||||
:key="tab.to"
|
||||
:to="tab.to"
|
||||
class="selfserve-tabs__link"
|
||||
:class="{ 'selfserve-tabs__link--active': isActive(tab) }"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selfserve-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
border-bottom: 1px solid #dbe4ea;
|
||||
}
|
||||
|
||||
.selfserve-tabs__link {
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.selfserve-tabs__link--active {
|
||||
color: #081248;
|
||||
border-bottom-color: #4f7cff;
|
||||
}
|
||||
</style>
|
||||
+90
-1
@@ -21,6 +21,21 @@ export type LaneCommandPayload = {
|
||||
customer_number?: number | null;
|
||||
license_plate?: string | null;
|
||||
};
|
||||
export type SelfServeSessionListParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
filters?: string;
|
||||
order?: string;
|
||||
open_only?: boolean;
|
||||
active_only?: boolean;
|
||||
};
|
||||
export type ForceStopLaneOptions = {
|
||||
laneId: number;
|
||||
sessionId?: number | null;
|
||||
bill: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
const relayStatusGetters: Record<RelayKind, (laneId: number) => Promise<any>> = {
|
||||
MACHINE: (laneId: number) => SessionUser.request(
|
||||
@@ -81,6 +96,14 @@ const normalizeLaneId = (laneId: number): number => {
|
||||
return parsedLaneId;
|
||||
};
|
||||
|
||||
const normalizePositiveId = (value: number | string | null | undefined, label: string): number => {
|
||||
const parsedId = Number.parseInt(String(value), 10);
|
||||
if (!Number.isInteger(parsedId) || parsedId <= 0) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
return parsedId;
|
||||
};
|
||||
|
||||
const normalizeLicensePlate = (licensePlate: string | null = null): string | null => {
|
||||
if (typeof licensePlate !== "string") {
|
||||
return null;
|
||||
@@ -110,6 +133,47 @@ const executeLaneCommand = (
|
||||
}
|
||||
);
|
||||
|
||||
const normalizeSessionListParams = (params: SelfServeSessionListParams = {}): SelfServeSessionListParams => {
|
||||
const payload: SelfServeSessionListParams = {};
|
||||
|
||||
if (params.page !== undefined) {
|
||||
payload.page = Math.max(1, Math.round(Number(params.page) || 1));
|
||||
}
|
||||
if (params.limit !== undefined) {
|
||||
payload.limit = Math.max(1, Math.round(Number(params.limit) || 25));
|
||||
}
|
||||
if (typeof params.search === "string" && params.search.trim().length > 0) {
|
||||
payload.search = params.search.trim();
|
||||
}
|
||||
if (typeof params.filters === "string" && params.filters.trim().length > 0) {
|
||||
payload.filters = params.filters.trim();
|
||||
}
|
||||
if (typeof params.order === "string" && params.order.trim().length > 0) {
|
||||
payload.order = params.order.trim();
|
||||
}
|
||||
if (params.open_only !== undefined) {
|
||||
payload.open_only = Boolean(params.open_only);
|
||||
}
|
||||
if (params.active_only !== undefined) {
|
||||
payload.active_only = Boolean(params.active_only);
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const sessions = {
|
||||
list: (params: SelfServeSessionListParams = {}) => SessionUser.request(
|
||||
"/modules/self-serve/sessions",
|
||||
"GET",
|
||||
normalizeSessionListParams(params)
|
||||
),
|
||||
detail: (sessionId: number) => SessionUser.request(
|
||||
`/modules/self-serve/sessions/${normalizePositiveId(sessionId, "session id")}`,
|
||||
"GET",
|
||||
{}
|
||||
),
|
||||
};
|
||||
|
||||
export const relays = {
|
||||
get: {
|
||||
status: {
|
||||
@@ -172,6 +236,31 @@ export const lane = {
|
||||
),
|
||||
},
|
||||
force: {
|
||||
stop: (options: ForceStopLaneOptions) => {
|
||||
const payload: {
|
||||
lane_id: number;
|
||||
session_id?: number;
|
||||
bill: boolean;
|
||||
reason?: string;
|
||||
} = {
|
||||
lane_id: normalizeLaneId(options.laneId),
|
||||
bill: Boolean(options.bill),
|
||||
};
|
||||
|
||||
if (options.sessionId !== undefined && options.sessionId !== null) {
|
||||
payload.session_id = normalizePositiveId(options.sessionId, "session id");
|
||||
}
|
||||
|
||||
if (typeof options.reason === "string" && options.reason.trim().length > 0) {
|
||||
payload.reason = options.reason.trim();
|
||||
}
|
||||
|
||||
return SessionUser.request(
|
||||
"/modules/self-serve/lane/force/stop",
|
||||
"POST",
|
||||
payload
|
||||
);
|
||||
},
|
||||
machine: {
|
||||
enable: (
|
||||
laneId: number,
|
||||
@@ -226,5 +315,5 @@ export const lane = {
|
||||
},
|
||||
};
|
||||
|
||||
export default { relays, lane, relayKinds }
|
||||
export default { relays, lane, relayKinds, sessions }
|
||||
</script>
|
||||
|
||||
+21
-2
@@ -3,17 +3,19 @@ import { computed, reactive } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
lane,
|
||||
sessions,
|
||||
type LaneGate,
|
||||
relayKinds,
|
||||
relays,
|
||||
type ForceStopLaneOptions,
|
||||
type LaneCommand,
|
||||
type LaneCommandPayload,
|
||||
type RelayKind,
|
||||
type RelayStatus,
|
||||
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeConnectivityRelay.vue";
|
||||
|
||||
export { lane, relayKinds, relays };
|
||||
export type { LaneCommand, LaneCommandPayload, LaneGate, RelayKind, RelayStatus };
|
||||
export { lane, relayKinds, relays, sessions };
|
||||
export type { ForceStopLaneOptions, LaneCommand, LaneCommandPayload, LaneGate, RelayKind, RelayStatus };
|
||||
|
||||
type RelayStatusMap = Record<RelayKind, RelayStatus | null>;
|
||||
type RelayLoadingMap = Record<RelayKind, boolean>;
|
||||
@@ -21,6 +23,7 @@ type LaneCommandLoadingState = {
|
||||
command: boolean;
|
||||
forceEnable: boolean;
|
||||
forceDisable: boolean;
|
||||
forceStop: boolean;
|
||||
openEntranceGate: boolean;
|
||||
openExitGate: boolean;
|
||||
openOutsideEntranceGate: boolean;
|
||||
@@ -69,6 +72,7 @@ const createInitialCommandLoadingState = (): LaneCommandLoadingState => ({
|
||||
command: false,
|
||||
forceEnable: false,
|
||||
forceDisable: false,
|
||||
forceStop: false,
|
||||
openEntranceGate: false,
|
||||
openExitGate: false,
|
||||
openOutsideEntranceGate: false,
|
||||
@@ -446,6 +450,19 @@ export const forceDisableMachine = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const forceStopLane = async (
|
||||
options: ForceStopLaneOptions
|
||||
): Promise<any> => {
|
||||
const normalizedLaneId = toLaneId(options.laneId);
|
||||
return runLaneAction(normalizedLaneId, "forceStop", async () => {
|
||||
const response = await lane.force.stop({
|
||||
...options,
|
||||
laneId: normalizedLaneId,
|
||||
});
|
||||
return extractPayload(response);
|
||||
});
|
||||
};
|
||||
|
||||
export const stopWash = (laneId: number): Promise<any> => executeLaneCommand(laneId, "STOP");
|
||||
|
||||
export const openGate = async (
|
||||
@@ -582,6 +599,7 @@ export const useMachineConnectivity = (laneId: number) => {
|
||||
openOutsideExitGate: () => openOutsideExitGate(normalizedLaneId),
|
||||
forceEnableMachine: (options: ForceEnableOptions = {}) => forceEnableMachine(normalizedLaneId, options),
|
||||
forceDisableMachine: (licensePlate: string | null = null) => forceDisableMachine(normalizedLaneId, licensePlate),
|
||||
forceStopLane: (options: Omit<ForceStopLaneOptions, "laneId">) => forceStopLane({ ...options, laneId: normalizedLaneId }),
|
||||
startPolling: (intervalMs = DEFAULT_POLL_INTERVAL_MS) => startPolling(normalizedLaneId, intervalMs),
|
||||
stopPolling: () => stopPolling(normalizedLaneId),
|
||||
clearError: () => clearLaneError(normalizedLaneId),
|
||||
@@ -609,6 +627,7 @@ export const machineConnectivityStore = {
|
||||
executeLaneCommand,
|
||||
forceEnableMachine,
|
||||
forceDisableMachine,
|
||||
forceStopLane,
|
||||
stopWash,
|
||||
openGate,
|
||||
openEntranceGate,
|
||||
|
||||
@@ -60,6 +60,25 @@ test.describe("Edge gateway routing and fleet navigation", () => {
|
||||
await expect(page.getByTestId("gateway-fleet-item-701")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("saves broker module configuration values", async ({ page }) => {
|
||||
await page.goto("/superuser/configuration/edgegateway", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("gateway-module-broker-url")).toHaveValue("http://edge-broker:4300");
|
||||
await page.getByTestId("gateway-module-broker-url").fill("http://edge-broker:4301");
|
||||
await page.getByTestId("gateway-module-public-broker-url").fill("https://api.truckwash.io:4433/edge-broker");
|
||||
await page.getByTestId("gateway-module-broker-auth-mode").selectOption("manager");
|
||||
await page.getByTestId("gateway-module-broker-shared-secret").fill("updated-broker-secret");
|
||||
|
||||
const saveRequest = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && request.url().includes("/edgegateway/config")
|
||||
);
|
||||
await page.getByTestId("gateway-module-save").click();
|
||||
await saveRequest;
|
||||
|
||||
await expect(page.getByTestId("gateway-module-broker-url")).toHaveValue("http://edge-broker:4301");
|
||||
await expect(page.getByTestId("gateway-module-broker-shared-secret")).toHaveValue("updated-broker-secret");
|
||||
});
|
||||
|
||||
test("opens a department workspace from the fleet landing", async ({ page }) => {
|
||||
await page.goto("/superuser/configuration/edgegateway");
|
||||
|
||||
|
||||
@@ -501,6 +501,11 @@ test.describe("Edge gateway management smoke", () => {
|
||||
await page.getByTestId("gateway-tab-logs").click();
|
||||
await expect(page.getByTestId("gateway-logs-page")).toBeVisible();
|
||||
await expect(page.getByTestId("gateway-logs-timeline")).toContainText("GATEWAY_OPERATION_QUEUED");
|
||||
await expect(page.getByTestId("gateway-relay-logs")).toContainText("Relay SWITCH M-7 handled by local");
|
||||
await expect(page.getByTestId("gateway-relay-logs")).toContainText("selfserve");
|
||||
await expect(page.getByTestId("gateway-relay-logs")).toContainText("Customer 700123");
|
||||
await page.getByTestId("gateway-logs-type-filter").selectOption("relay");
|
||||
await expect(page.getByTestId("gateway-logs-timeline")).toContainText("Relay SWITCH M-7 handled by local");
|
||||
|
||||
await page.getByTestId("gateway-tab-statistics").click();
|
||||
await expect(page.getByTestId("gateway-statistics-page")).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
function waitForForceStopRequest(page, bill) {
|
||||
return page.waitForRequest((request) => {
|
||||
if (!request.url().includes("/modules/self-serve/lane/force/stop")) {
|
||||
return false;
|
||||
}
|
||||
if (request.method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
const body = request.postDataJSON?.() || {};
|
||||
return body.bill === bill;
|
||||
});
|
||||
}
|
||||
|
||||
async function warmSelfServeSessionsRoute(page) {
|
||||
const routeModules = [
|
||||
"/src/views/dashboards/superUserDashboard/SelfServeSessions.vue",
|
||||
"/src/views/dashboards/superUserDashboard/selfserve/SelfServeSuperUserTabs.vue",
|
||||
"/src/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue",
|
||||
"/src/views/dashboards/superUserDashboard/selfserve/components/SelfServeConnectivityRelay.vue",
|
||||
];
|
||||
|
||||
for (const routeModule of routeModules) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await page.request.get(routeModule);
|
||||
return response.status();
|
||||
},
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
async function gotoSessionsPage(page) {
|
||||
const heading = page.getByRole("heading", { name: "Selvvask Sessions" });
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await page.goto("/superuser/selfserve/sessions", { waitUntil: "domcontentloaded", timeout: 120_000 });
|
||||
await expect(page).toHaveURL(/\/superuser\/selfserve\/sessions$/);
|
||||
|
||||
try {
|
||||
await expect(heading).toBeVisible({ timeout: 45_000 });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === 1) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Self-serve sessions management", () => {
|
||||
test("lists, filters, opens detail, and force-stops sessions with and without billing", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: [
|
||||
"user",
|
||||
"superuser",
|
||||
"modules_selfserve_sessions_view",
|
||||
"modules_selfserve_sessions_force_stop",
|
||||
"modules_selfserve_sessions_force_stop_bill",
|
||||
],
|
||||
selfServe: true,
|
||||
});
|
||||
await seedAuthenticatedState(page, "self-serve-sessions-token");
|
||||
await warmSelfServeSessionsRoute(page);
|
||||
await gotoSessionsPage(page);
|
||||
const row701 = page.locator("tr", { hasText: "#701" });
|
||||
const row702 = page.locator("tr", { hasText: "#702" });
|
||||
await expect(row701).toContainText("AB12345");
|
||||
await expect(row702).toContainText("CD67890");
|
||||
|
||||
await page.getByRole("searchbox", { name: "Search" }).fill("CD67890");
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
await expect(row702).toBeVisible();
|
||||
await expect(row701).toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
await expect(row701).toBeVisible();
|
||||
await row701.click();
|
||||
const detail = page.getByRole("complementary");
|
||||
await expect(detail).toContainText("Session #701");
|
||||
await expect(detail).toContainText("Is the tarp removed?");
|
||||
await expect(detail).toContainText("SESSION_SYNCED");
|
||||
|
||||
await row701.getByRole("button", { name: "Force stop" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toContainText("does not signal relays or gates");
|
||||
await dialog.getByLabel("Bill elapsed minutes").check();
|
||||
await expect(dialog).toContainText("elapsed-minute only");
|
||||
await dialog.getByLabel("Reason").fill("Operator emergency clear");
|
||||
|
||||
const billRequestPromise = waitForForceStopRequest(page, true);
|
||||
await dialog.getByRole("button", { name: "Force stop" }).click();
|
||||
const billRequest = await billRequestPromise;
|
||||
expect(billRequest.postDataJSON?.()).toMatchObject({
|
||||
lane_id: 7,
|
||||
session_id: 701,
|
||||
bill: true,
|
||||
reason: "Operator emergency clear",
|
||||
});
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(detail).toContainText("SESSION_FORCE_STOPPED");
|
||||
|
||||
await row702.getByRole("button", { name: "Force stop" }).click();
|
||||
const noBillDialog = page.getByRole("dialog");
|
||||
await expect(noBillDialog.getByLabel("Bill elapsed minutes")).not.toBeChecked();
|
||||
const noBillRequestPromise = waitForForceStopRequest(page, false);
|
||||
await noBillDialog.getByRole("button", { name: "Force stop" }).click();
|
||||
const noBillRequest = await noBillRequestPromise;
|
||||
expect(noBillRequest.postDataJSON?.()).toMatchObject({
|
||||
lane_id: 9,
|
||||
session_id: 702,
|
||||
bill: false,
|
||||
});
|
||||
|
||||
await expect(page.getByText("No sessions found.")).toBeVisible();
|
||||
});
|
||||
});
|
||||
+338
-14
@@ -55,6 +55,10 @@ function mergeFixture(base, overrides = {}) {
|
||||
...(base.summaryBySessionId || {}),
|
||||
...(overrides.summaryBySessionId || {}),
|
||||
},
|
||||
sessionDetailsById: {
|
||||
...(base.sessionDetailsById || {}),
|
||||
...(overrides.sessionDetailsById || {}),
|
||||
},
|
||||
summaryByKey: {
|
||||
...(base.summaryByKey || {}),
|
||||
...(overrides.summaryByKey || {}),
|
||||
@@ -589,10 +593,125 @@ function createSelfServeFixture(overrides = {}) {
|
||||
},
|
||||
];
|
||||
|
||||
const sessions = [
|
||||
{
|
||||
id: 701,
|
||||
lane_id: 7,
|
||||
department_id: 6,
|
||||
machine_type_id: null,
|
||||
customer_number: 12345679,
|
||||
vehicle_id: 1,
|
||||
vehicle_type_id: 2,
|
||||
reg: "AB12345",
|
||||
status: "MACHINE_STARTED",
|
||||
allowed: true,
|
||||
machine_relay_enabled: true,
|
||||
machine_relay_enabled_at: "2026-04-28 08:15:00",
|
||||
machine_start_triggered: true,
|
||||
machine_start_triggered_at: "2026-04-28 08:16:00",
|
||||
wash_started_at: "2026-04-28 08:16:00",
|
||||
order_id: null,
|
||||
completed_at: null,
|
||||
metadata: {
|
||||
evaluation_trace: [{ task_id: 9001, satisfied: true }],
|
||||
},
|
||||
elapsed_minutes: 18,
|
||||
open: true,
|
||||
created_at: "2026-04-28 08:10:00",
|
||||
updated_at: "2026-04-28 08:16:00",
|
||||
},
|
||||
{
|
||||
id: 702,
|
||||
lane_id: 9,
|
||||
department_id: 2,
|
||||
machine_type_id: null,
|
||||
customer_number: 12345680,
|
||||
vehicle_id: 2,
|
||||
vehicle_type_id: 3,
|
||||
reg: "CD67890",
|
||||
status: "MACHINE_RELAY_ENABLED",
|
||||
allowed: true,
|
||||
machine_relay_enabled: true,
|
||||
machine_relay_enabled_at: "2026-04-28 09:05:00",
|
||||
machine_start_triggered: false,
|
||||
machine_start_triggered_at: null,
|
||||
wash_started_at: "2026-04-28 09:05:00",
|
||||
order_id: null,
|
||||
completed_at: null,
|
||||
metadata: {},
|
||||
elapsed_minutes: 8,
|
||||
open: true,
|
||||
created_at: "2026-04-28 09:00:00",
|
||||
updated_at: "2026-04-28 09:05:00",
|
||||
},
|
||||
{
|
||||
id: 703,
|
||||
lane_id: 7,
|
||||
department_id: 6,
|
||||
machine_type_id: null,
|
||||
customer_number: 12345681,
|
||||
vehicle_id: null,
|
||||
vehicle_type_id: null,
|
||||
reg: "ZZ00000",
|
||||
status: "COMPLETED",
|
||||
allowed: true,
|
||||
machine_relay_enabled: false,
|
||||
machine_relay_enabled_at: null,
|
||||
machine_start_triggered: true,
|
||||
machine_start_triggered_at: "2026-04-27 15:30:00",
|
||||
wash_started_at: "2026-04-27 15:30:00",
|
||||
order_id: 8800,
|
||||
completed_at: "2026-04-27 15:55:00",
|
||||
metadata: {},
|
||||
elapsed_minutes: 25,
|
||||
open: false,
|
||||
created_at: "2026-04-27 15:25:00",
|
||||
updated_at: "2026-04-27 15:55:00",
|
||||
},
|
||||
];
|
||||
|
||||
const baseFixture = {
|
||||
departments,
|
||||
departmentLanes: departments.flatMap((department) => department.lanes),
|
||||
products,
|
||||
sessions,
|
||||
sessionDetailsById: {
|
||||
701: {
|
||||
session: sessions[0],
|
||||
lane: { id: 7, name: "7", department: 6 },
|
||||
machine_type: null,
|
||||
questions: [
|
||||
{ question_id: 11, question: "Is the tarp removed?", answer: true, answered_at: "2026-04-28 08:12:00" },
|
||||
],
|
||||
tasks: [{ task_id: 9001, task: "Prepare the truck", services: ["MACHINE"], description: "Complete checks." }],
|
||||
events: [
|
||||
{ id: 1, type: "SESSION_SYNCED", payload: { allowed: true }, created_at: "2026-04-28 08:10:00" },
|
||||
{ id: 2, type: "MACHINE_START_TRIGGERED", payload: { lane_id: 7 }, created_at: "2026-04-28 08:16:00" },
|
||||
],
|
||||
config_version_id: 12,
|
||||
evaluation_trace: [{ task_id: 9001, satisfied: true }],
|
||||
},
|
||||
702: {
|
||||
session: sessions[1],
|
||||
lane: { id: 9, name: "9", department: 2 },
|
||||
machine_type: null,
|
||||
questions: [],
|
||||
tasks: [{ task_id: 9002, task: "Machine access", services: ["MACHINE"], description: "" }],
|
||||
events: [{ id: 3, type: "SESSION_SYNCED", payload: { allowed: true }, created_at: "2026-04-28 09:00:00" }],
|
||||
config_version_id: 12,
|
||||
evaluation_trace: [],
|
||||
},
|
||||
703: {
|
||||
session: sessions[2],
|
||||
lane: { id: 7, name: "7", department: 6 },
|
||||
machine_type: null,
|
||||
questions: [],
|
||||
tasks: [],
|
||||
events: [{ id: 4, type: "SESSION_COMPLETED", payload: { order_id: 8800 }, created_at: "2026-04-27 15:55:00" }],
|
||||
config_version_id: 11,
|
||||
evaluation_trace: [],
|
||||
},
|
||||
},
|
||||
customerVehicles: [
|
||||
{ id: 1, reg: "AB12345", type: 2 },
|
||||
{ id: 2, reg: "CD67890", type: 3 },
|
||||
@@ -726,6 +845,9 @@ function createSelfServeFixture(overrides = {}) {
|
||||
laneAllowedServices: ["MACHINE"],
|
||||
commandResponse: { success: true },
|
||||
commandResponses: null,
|
||||
forceStopResponse: null,
|
||||
forceStopResponses: null,
|
||||
forceStopRequests: [],
|
||||
relayResponse: { success: true },
|
||||
dynamicImage: TINY_PNG,
|
||||
};
|
||||
@@ -736,6 +858,22 @@ function createSelfServeFixture(overrides = {}) {
|
||||
accumulator[String(lane.id)] = lane;
|
||||
return accumulator;
|
||||
}, {});
|
||||
fixture.sessionDetailsById = {
|
||||
...(fixture.sessions || []).reduce((accumulator, session) => {
|
||||
accumulator[String(session.id)] = fixture.sessionDetailsById?.[session.id] || {
|
||||
session,
|
||||
lane: fixture.laneById[String(session.lane_id)] || null,
|
||||
machine_type: null,
|
||||
questions: [],
|
||||
tasks: [],
|
||||
events: [],
|
||||
config_version_id: null,
|
||||
evaluation_trace: session.metadata?.evaluation_trace || null,
|
||||
};
|
||||
return accumulator;
|
||||
}, {}),
|
||||
...(fixture.sessionDetailsById || {}),
|
||||
};
|
||||
return fixture;
|
||||
}
|
||||
|
||||
@@ -1286,6 +1424,7 @@ function buildHttpEdgeGatewayLogsPage(edgeGatewayFixture, gateway) {
|
||||
const gatewayPayload = buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true);
|
||||
const auditLogs = cloneJson(gateway.audit_logs || []);
|
||||
const logEntries = cloneJson(gateway.log_entries || []);
|
||||
const relayLogs = logEntries.filter((entry) => String(entry.stream || "").toLowerCase() === "relay");
|
||||
const shellSessions = cloneJson(edgeGatewayFixture.shellSessionsByGatewayId?.[gateway.id] || []);
|
||||
const timeline = [
|
||||
...auditLogs.map((entry) => ({
|
||||
@@ -1296,7 +1435,7 @@ function buildHttpEdgeGatewayLogsPage(edgeGatewayFixture, gateway) {
|
||||
entry,
|
||||
})),
|
||||
...logEntries.map((entry) => ({
|
||||
type: "log",
|
||||
type: String(entry.stream || "").toLowerCase() === "relay" ? "relay" : "log",
|
||||
level: entry.level || "INFO",
|
||||
message: entry.message || "",
|
||||
created_at: entry.created_at || null,
|
||||
@@ -1322,6 +1461,7 @@ function buildHttpEdgeGatewayLogsPage(edgeGatewayFixture, gateway) {
|
||||
timeline,
|
||||
audit_logs: auditLogs,
|
||||
log_entries: logEntries,
|
||||
relay_logs: relayLogs,
|
||||
shell_sessions: shellSessions,
|
||||
};
|
||||
}
|
||||
@@ -1750,6 +1890,40 @@ function createHttpEdgeGatewayFixture(options = {}) {
|
||||
level: "INFO",
|
||||
message: "Broker session connected and telemetry streaming.",
|
||||
},
|
||||
{
|
||||
id: 803,
|
||||
created_at: "2026-04-08 08:14:57",
|
||||
level: "INFO",
|
||||
stream: "relay",
|
||||
source: "RELAY_DISPATCH",
|
||||
message: "Relay SWITCH M-7 handled by local via BROKER_FAST_PATH",
|
||||
context: {
|
||||
module: "selfserve",
|
||||
module_responsible: "selfserve",
|
||||
reason: "Set self-serve relay ON",
|
||||
handler: "local",
|
||||
delivery_channel: "BROKER_FAST_PATH",
|
||||
relay_id: "M-7",
|
||||
action: "SWITCH",
|
||||
target_on: true,
|
||||
associated: {
|
||||
admin_user_id: 91,
|
||||
customer_number: 700123,
|
||||
},
|
||||
signal: {
|
||||
command_type: "SET_RELAY_STATE",
|
||||
relay_id: "M-7",
|
||||
request: {
|
||||
relayId: "M-7",
|
||||
on: true,
|
||||
},
|
||||
},
|
||||
response: {
|
||||
online: true,
|
||||
on: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
operations: [
|
||||
createFixtureOperation(
|
||||
@@ -1847,6 +2021,10 @@ function createHttpEdgeGatewayFixture(options = {}) {
|
||||
enabled: options.config?.enabled ?? true,
|
||||
default_release_channel: options.config?.default_release_channel || "stable",
|
||||
default_update_window: options.config?.default_update_window || "02:00-04:00",
|
||||
broker_url: options.config?.broker_url || "http://edge-broker:4300",
|
||||
public_broker_url: options.config?.public_broker_url || "https://api.truckwash.io:4433/edge-broker",
|
||||
broker_auth_mode: options.config?.broker_auth_mode || "manager",
|
||||
broker_shared_secret: options.config?.broker_shared_secret || "truckwash-edge-dev",
|
||||
},
|
||||
installSessionsById,
|
||||
pendingClaims: [],
|
||||
@@ -4037,6 +4215,10 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
||||
{ variable: "enabled", value: edgeGatewayFixture.config.enabled },
|
||||
{ variable: "default_release_channel", value: edgeGatewayFixture.config.default_release_channel },
|
||||
{ variable: "default_update_window", value: edgeGatewayFixture.config.default_update_window },
|
||||
{ variable: "broker_url", value: edgeGatewayFixture.config.broker_url },
|
||||
{ variable: "public_broker_url", value: edgeGatewayFixture.config.public_broker_url },
|
||||
{ variable: "broker_auth_mode", value: edgeGatewayFixture.config.broker_auth_mode },
|
||||
{ variable: "broker_shared_secret", value: edgeGatewayFixture.config.broker_shared_secret },
|
||||
];
|
||||
|
||||
if (pathname.endsWith("/edgegateway/config") && method === "GET") {
|
||||
@@ -4046,20 +4228,12 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
||||
|
||||
if (pathname.endsWith("/edgegateway/config") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const variable = String(body.variable || "");
|
||||
const updates = Object.prototype.hasOwnProperty.call(body, "variable")
|
||||
? [[String(body.variable || ""), body.value]]
|
||||
: Object.entries(body);
|
||||
const allowedVariables = new Set(buildConfigEntries().map((entry) => entry.variable));
|
||||
|
||||
if (variable === "enabled") {
|
||||
edgeGatewayFixture.config.enabled = !(
|
||||
body.value === false ||
|
||||
body.value === "false" ||
|
||||
body.value === 0 ||
|
||||
body.value === "0"
|
||||
);
|
||||
} else if (variable === "default_release_channel") {
|
||||
edgeGatewayFixture.config.default_release_channel = String(body.value || "stable");
|
||||
} else if (variable === "default_update_window") {
|
||||
edgeGatewayFixture.config.default_update_window = String(body.value || "02:00-04:00");
|
||||
} else {
|
||||
if (updates.length === 0 || updates.some(([variable]) => !allowedVariables.has(String(variable)))) {
|
||||
await route.fulfill(
|
||||
json(
|
||||
{
|
||||
@@ -4074,6 +4248,24 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const [variable, value] of updates) {
|
||||
if (variable === "enabled") {
|
||||
edgeGatewayFixture.config.enabled = !(value === false || value === "false" || value === 0 || value === "0");
|
||||
} else if (variable === "default_release_channel") {
|
||||
edgeGatewayFixture.config.default_release_channel = String(value || "stable");
|
||||
} else if (variable === "default_update_window") {
|
||||
edgeGatewayFixture.config.default_update_window = String(value || "02:00-04:00");
|
||||
} else if (variable === "broker_url") {
|
||||
edgeGatewayFixture.config.broker_url = String(value || "");
|
||||
} else if (variable === "public_broker_url") {
|
||||
edgeGatewayFixture.config.public_broker_url = String(value || "");
|
||||
} else if (variable === "broker_auth_mode") {
|
||||
edgeGatewayFixture.config.broker_auth_mode = String(value || "manager");
|
||||
} else if (variable === "broker_shared_secret") {
|
||||
edgeGatewayFixture.config.broker_shared_secret = String(value || "");
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill(json({ data: buildConfigEntries() }));
|
||||
return true;
|
||||
}
|
||||
@@ -5595,6 +5787,138 @@ export async function mockApi(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/modules/self-serve/sessions") && method === "GET") {
|
||||
const filterMap = parseFilterExpressions(parsedUrl.searchParams.get("filters") || "");
|
||||
const search = String(parsedUrl.searchParams.get("search") || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const openOnly = ["1", "true", "yes", "on"].includes(
|
||||
String(parsedUrl.searchParams.get("open_only") || "").toLowerCase()
|
||||
);
|
||||
const [orderBy = "id", orderDirection = "DESC"] = String(
|
||||
parsedUrl.searchParams.get("order") || "id:DESC"
|
||||
).split(":");
|
||||
|
||||
const filteredSessions = (selfServe.sessions || [])
|
||||
.filter((session) => {
|
||||
if (filterMap.department_id && Number(session.department_id) !== Number(filterMap.department_id)) {
|
||||
return false;
|
||||
}
|
||||
if (filterMap.lane_id && Number(session.lane_id) !== Number(filterMap.lane_id)) {
|
||||
return false;
|
||||
}
|
||||
if (filterMap.status && String(session.status || "") !== String(filterMap.status)) {
|
||||
return false;
|
||||
}
|
||||
if (openOnly && session.completed_at) {
|
||||
return false;
|
||||
}
|
||||
if (search) {
|
||||
return [session.id, session.reg, session.customer_number].some((value) =>
|
||||
String(value || "")
|
||||
.toUpperCase()
|
||||
.includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftValue = left?.[orderBy];
|
||||
const rightValue = right?.[orderBy];
|
||||
if (leftValue === rightValue) {
|
||||
return 0;
|
||||
}
|
||||
const result = leftValue > rightValue ? 1 : -1;
|
||||
return String(orderDirection).toUpperCase() === "ASC" ? result : -result;
|
||||
});
|
||||
|
||||
const { rows, meta } = paginateRows(
|
||||
filteredSessions,
|
||||
parsedUrl.searchParams.get("page") || 1,
|
||||
parsedUrl.searchParams.get("limit") || 25
|
||||
);
|
||||
|
||||
await route.fulfill(json({ data: rows, meta }));
|
||||
return;
|
||||
}
|
||||
|
||||
const selfServeSessionDetailMatch = pathname.match(/\/modules\/self-serve\/sessions\/(\d+)$/);
|
||||
if (selfServeSessionDetailMatch && method === "GET") {
|
||||
const sessionId = selfServeSessionDetailMatch[1];
|
||||
const detail = selfServe.sessionDetailsById?.[sessionId] || null;
|
||||
await route.fulfill(detail ? json({ data: detail }) : json({ message: "Session not found" }, 404));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/modules/self-serve/lane/force/stop") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
selfServe.forceStopRequests.push(body);
|
||||
const sessionId = Number(body.session_id || 0);
|
||||
const session = (selfServe.sessions || []).find((entry) => Number(entry.id) === sessionId) || null;
|
||||
const completedAt = toSqlDateTime("2026-04-28T10:30:00Z");
|
||||
const orderId = body.bill ? 8801 : null;
|
||||
|
||||
if (session) {
|
||||
session.status = "FORCE_STOPPED";
|
||||
session.completed_at = completedAt;
|
||||
session.open = false;
|
||||
session.order_id = orderId;
|
||||
|
||||
const detail = selfServe.sessionDetailsById[String(session.id)] || {
|
||||
session,
|
||||
lane: selfServe.laneById[String(session.lane_id)] || null,
|
||||
machine_type: null,
|
||||
questions: [],
|
||||
tasks: [],
|
||||
events: [],
|
||||
config_version_id: null,
|
||||
evaluation_trace: null,
|
||||
};
|
||||
detail.session = session;
|
||||
detail.events = [
|
||||
...(detail.events || []),
|
||||
{
|
||||
id: 9000 + Number(session.id),
|
||||
type: "SESSION_FORCE_STOPPED",
|
||||
payload: {
|
||||
lane_id: Number(body.lane_id || session.lane_id),
|
||||
bill: Boolean(body.bill),
|
||||
reason: body.reason || null,
|
||||
order_id: orderId,
|
||||
},
|
||||
created_at: completedAt,
|
||||
},
|
||||
];
|
||||
selfServe.sessionDetailsById[String(session.id)] = detail;
|
||||
}
|
||||
|
||||
const forceStopResponse =
|
||||
Array.isArray(selfServe.forceStopResponses) && selfServe.forceStopResponses.length > 0
|
||||
? selfServe.forceStopResponses.shift()
|
||||
: selfServe.forceStopResponse;
|
||||
|
||||
await route.fulfill(
|
||||
json(
|
||||
forceStopResponse || {
|
||||
success: true,
|
||||
data: {
|
||||
lane_id: Number(body.lane_id || 0),
|
||||
forced: true,
|
||||
bill: Boolean(body.bill),
|
||||
order_id: orderId,
|
||||
session: session ? selfServe.sessionDetailsById[String(session.id)] : null,
|
||||
runtime_before_reset: {
|
||||
status: "OCCUPIED",
|
||||
state: "IN_WASH",
|
||||
elapsed_wash_time: 1200,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/modules/self-serve/lane/services/allowed") && method === "POST") {
|
||||
await route.fulfill(
|
||||
json({
|
||||
|
||||
@@ -69,6 +69,9 @@ describe("edge gateway workspace contract", () => {
|
||||
expect(tasksSource).toContain('data-testid="gateway-operation-cancel"');
|
||||
expect(tasksSource).toContain('data-testid="gateway-operation-retry"');
|
||||
expect(logsSource).toContain('data-testid="gateway-logs-page"');
|
||||
expect(logsSource).toContain('data-testid="gateway-relay-logs"');
|
||||
expect(logsSource).toContain('<option value="relay">Relay</option>');
|
||||
expect(managerSource).toContain(':relay-logs="logRelayLogs"');
|
||||
expect(statisticsSource).toContain('data-testid="gateway-statistics-page"');
|
||||
expect(terminalSource).toContain('data-testid="gateway-terminal-page"');
|
||||
expect(terminalSource).toContain('data-testid="gateway-terminal-status"');
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
lane,
|
||||
relayKinds,
|
||||
relays,
|
||||
sessions,
|
||||
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeConnectivityRelay.vue";
|
||||
|
||||
const statusEndpoints = {
|
||||
@@ -141,10 +142,55 @@ describe("self-serve connectivity relay api wrappers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes session list, detail, and force stop payloads", async () => {
|
||||
await sessions.list({
|
||||
page: "2",
|
||||
limit: "50",
|
||||
search: " AB12345 ",
|
||||
filters: " lane_id:7 ",
|
||||
order: " id:DESC ",
|
||||
open_only: true,
|
||||
});
|
||||
await sessions.detail("55");
|
||||
await lane.force.stop({
|
||||
laneId: "7",
|
||||
sessionId: "55",
|
||||
bill: true,
|
||||
reason: " operator reset ",
|
||||
});
|
||||
await lane.force.stop({
|
||||
laneId: 7,
|
||||
sessionId: null,
|
||||
bill: false,
|
||||
reason: " ",
|
||||
});
|
||||
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(1, "/modules/self-serve/sessions", "GET", {
|
||||
page: 2,
|
||||
limit: 50,
|
||||
search: "AB12345",
|
||||
filters: "lane_id:7",
|
||||
order: "id:DESC",
|
||||
open_only: true,
|
||||
});
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(2, "/modules/self-serve/sessions/55", "GET", {});
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(3, "/modules/self-serve/lane/force/stop", "POST", {
|
||||
lane_id: 7,
|
||||
session_id: 55,
|
||||
bill: true,
|
||||
reason: "operator reset",
|
||||
});
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(4, "/modules/self-serve/lane/force/stop", "POST", {
|
||||
lane_id: 7,
|
||||
bill: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid lane ids for relay and lane commands", async () => {
|
||||
await expect(relays.get.status.all(0)).rejects.toThrow("Invalid lane id");
|
||||
expect(() => lane.command.START(0)).toThrow("Invalid lane id");
|
||||
expect(() => lane.force.machine.enable(Number.NaN)).toThrow("Invalid lane id");
|
||||
expect(() => lane.force.stop({ laneId: 1, sessionId: 0, bill: false })).toThrow("Invalid session id");
|
||||
expect(() => lane.gate.open(1, "SIDE")).toThrow("Invalid gate");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +145,7 @@ describe("self-serve machine connectivity store", () => {
|
||||
const connectivity = useMachineConnectivity(laneId);
|
||||
await connectivity.forceEnableMachine({ duration: 120, licensePlate: "ab12345" });
|
||||
await connectivity.forceDisableMachine("cd67890");
|
||||
await connectivity.forceStopLane({ sessionId: 91, bill: true, reason: "clear app runtime" });
|
||||
await connectivity.stopWash();
|
||||
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(1, "/modules/self-serve/lane/force/machine/enable", "POST", {
|
||||
@@ -156,7 +157,13 @@ describe("self-serve machine connectivity store", () => {
|
||||
lane_id: laneId,
|
||||
license_plate: "CD67890",
|
||||
});
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(3, "/modules/self-serve/lane/command", "POST", {
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(3, "/modules/self-serve/lane/force/stop", "POST", {
|
||||
lane_id: laneId,
|
||||
session_id: 91,
|
||||
bill: true,
|
||||
reason: "clear app runtime",
|
||||
});
|
||||
expect(mocks.request).toHaveBeenNthCalledWith(4, "/modules/self-serve/lane/command", "POST", {
|
||||
lane_id: laneId,
|
||||
command: "STOP",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user