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:
Jeppe Bundgaard
2026-04-28 10:03:36 +02:00
parent e26e37d9d7
commit 61aab76e63
19 changed files with 1775 additions and 21 deletions
@@ -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"
+7
View File
@@ -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',
+7 -2
View File
@@ -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>
@@ -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>
@@ -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,
+19
View File
@@ -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");
+5
View File
@@ -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();
+124
View File
@@ -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
View File
@@ -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",
});