Add runtime support for service sets, release management enhancements, and new unit tests

- Extend `releaseTimeline.js` and related modules for runtime-aware service sets and URLs.
- Update the release session builder to include `service_set` handling.
- Introduce release service definitions, status tones, and enhanced runtime display text.
- Add new dataset mode options, preview rows, and warnings for release configurations in `ConfigurationReleaseManager.vue`.
- Implement extensive utility methods for data service handling, target context, and deployment endpoints.
- Add `vite-api-proxy.spec.js` and extend `release-bootstrap.spec.js` to ensure coverage for new functionality.
This commit is contained in:
Jeppe Bundgaard
2026-05-21 11:41:50 +02:00
parent 6ba1c754a1
commit eaf4b965d5
16 changed files with 2622 additions and 220 deletions
+293 -54
View File
@@ -2,7 +2,11 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import axios from "axios";
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import { getReleaseRuntimeApiBaseUrl, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
import {
buildReleaseSessionSummary,
getReleaseRuntimeApiBaseUrl,
resolveReleaseApiUrl,
} from "@/services/releaseTimeline.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
clearErrorRequests,
@@ -280,6 +284,7 @@ const resolvePingUrl = () => {
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
@@ -641,60 +646,186 @@ onBeforeUnmount(() => {
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<div class="request-queue-progress__section-title">Runtime</div>
<ul class="request-queue-progress__meta-list request-queue-progress__section-content request-queue-progress__section-content--meta">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Environment</span>
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Commit</span>
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version</span>
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version time</span>
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
</li>
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
<span class="request-queue-progress__meta-label">i18n catalog</span>
<button
class="request-queue-progress__catalog-switch"
data-testid="request-queue-i18n-catalog-switch"
type="button"
@click="handleToggleI18nCatalogVersion"
<div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span>
<span
class="request-queue-progress__meta-value"
:title="releaseSessionSummary.channelSlug || releaseSessionSummary.channelLabel"
>
{{ releaseSessionSummary.channelLabel }}
<span v-if="releaseSessionSummary.channelSlug">({{ releaseSessionSummary.channelSlug }})</span>
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Trace ID</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.traceId">
{{ releaseSessionSummary.traceId }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Generated</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.generatedAt">
{{ releaseSessionSummary.generatedAt }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Bundle</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.bundleLabel">
{{ releaseSessionSummary.bundleLabel }}
</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Service set</span>
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.serviceSetLabel">
{{ releaseSessionSummary.serviceSetLabel }}
</span>
</li>
<li
v-if="releaseSessionSummary.missingLabels.length > 0"
class="request-queue-progress__meta-item"
data-testid="request-queue-release-missing"
>
{{ i18nCatalogVersionLabel }}
</button>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing requests</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing responses</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
</li>
</ul>
<span class="request-queue-progress__meta-label">Missing</span>
<span
class="request-queue-progress__meta-value request-queue-progress__meta-value--warning"
:title="releaseSessionSummary.missingLabels.join(', ')"
>
{{ releaseSessionSummary.missingLabels.join(", ") }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">App releases</div>
<ul class="request-queue-progress__release-list">
<li
v-for="app in releaseSessionSummary.appRows"
:key="app.key"
class="request-queue-progress__release-row"
:data-testid="`request-queue-release-app-${app.key}`"
>
<div class="request-queue-progress__release-row-header">
<strong>{{ app.label }}</strong>
<span
class="request-queue-progress__status-pill"
:class="`request-queue-progress__status-pill--${app.tone}`"
>
{{ app.status }}
</span>
</div>
<span class="request-queue-progress__release-primary" :title="app.title">
{{ app.primaryText }}
</span>
<span
v-if="app.secondaryText"
class="request-queue-progress__release-secondary"
:title="app.secondaryText"
>
{{ app.secondaryText }}
</span>
<span
v-if="app.url"
class="request-queue-progress__release-url"
:title="app.url"
>
{{ app.url }}
</span>
<span
v-if="app.missingLabel"
class="request-queue-progress__release-secondary request-queue-progress__release-secondary--warning"
>
{{ app.missingLabel }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">Connected services</div>
<ul class="request-queue-progress__release-list">
<li
v-for="service in releaseSessionSummary.serviceRows"
:key="service.key"
class="request-queue-progress__release-row"
:data-testid="`request-queue-release-service-${service.key}`"
>
<div class="request-queue-progress__release-row-header">
<strong>{{ service.label }}</strong>
<span
class="request-queue-progress__status-pill"
:class="`request-queue-progress__status-pill--${service.tone}`"
>
{{ service.status }}
</span>
</div>
<span class="request-queue-progress__release-primary" :title="service.title">
{{ service.primaryText }}
</span>
<span
v-if="service.secondaryText"
class="request-queue-progress__release-secondary"
:title="service.secondaryText"
>
{{ service.secondaryText }}
</span>
</li>
</ul>
<div class="request-queue-progress__subsection-title">Runtime details</div>
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span>
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Environment</span>
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Commit</span>
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version</span>
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Version time</span>
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
</li>
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
<span class="request-queue-progress__meta-label">i18n catalog</span>
<button
class="request-queue-progress__catalog-switch"
data-testid="request-queue-i18n-catalog-switch"
type="button"
@click="handleToggleI18nCatalogVersion"
>
{{ i18nCatalogVersionLabel }}
</button>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing requests</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing responses</span>
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
</li>
<li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
</li>
</ul>
</div>
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--user" data-testid="request-queue-user-box">
@@ -931,6 +1062,12 @@ onBeforeUnmount(() => {
min-height: 56px;
}
.request-queue-progress__section-content--release {
display: flex;
flex-direction: column;
gap: 8px;
}
.request-queue-progress__section-title {
font-size: 12px;
font-weight: 600;
@@ -1170,6 +1307,108 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.request-queue-progress__meta-value--warning {
color: #fbbf24;
}
.request-queue-progress__subsection-title {
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
padding-top: 7px;
}
.request-queue-progress__release-list {
display: grid;
gap: 6px;
list-style: none;
margin: 0;
padding: 0;
}
.request-queue-progress__release-row {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
background: rgba(2, 6, 23, 0.28);
display: grid;
gap: 3px;
min-width: 0;
padding: 7px 8px;
}
.request-queue-progress__release-row-header {
align-items: center;
display: flex;
gap: 8px;
justify-content: space-between;
min-width: 0;
}
.request-queue-progress__release-row-header strong {
font-size: 12px;
line-height: 1.2;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__status-pill {
border-radius: 999px;
display: inline-flex;
flex: 0 0 auto;
font-size: 10px;
font-weight: 700;
line-height: 1;
max-width: 120px;
overflow: hidden;
padding: 3px 7px;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__status-pill--ok {
background: rgba(94, 234, 212, 0.16);
color: #99f6e4;
}
.request-queue-progress__status-pill--warning {
background: rgba(251, 191, 36, 0.16);
color: #fde68a;
}
.request-queue-progress__status-pill--danger {
background: rgba(248, 113, 113, 0.16);
color: #fecaca;
}
.request-queue-progress__release-primary,
.request-queue-progress__release-secondary,
.request-queue-progress__release-url {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__release-primary {
font-size: 12px;
line-height: 1.25;
}
.request-queue-progress__release-secondary,
.request-queue-progress__release-url {
color: rgba(255, 255, 255, 0.68);
font-size: 11px;
line-height: 1.25;
}
.request-queue-progress__release-secondary--warning {
color: #fbbf24;
}
.request-queue-progress__catalog-switch {
border: 1px solid rgba(126, 249, 227, 0.5);
border-radius: 999px;
+11 -7
View File
@@ -859,7 +859,11 @@ export const createOrder = async (options = { isMobile: false }) => {
parseError(response, "stepError");
return false;
}
order_id.value = parseInt(response.data.data.id);
const createdOrder = response.data.data || {};
order_id.value = parseInt(createdOrder.id);
if (!isBlankPosMetadataValue(createdOrder.po)) {
order_po.value = String(createdOrder.po);
}
if (selectedDepartmentId) {
department_id.value = selectedDepartmentId;
}
@@ -2078,11 +2082,6 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
try {
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
let booking = getSelectedPendingOrderBooking();
if (!booking || !Array.isArray(booking?.items)) {
booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId, {
@@ -2124,11 +2123,16 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
}
const bookingPo = String(booking?.po ?? "").trim();
if (bookingPo !== "" && bookingPo !== order_po.value) {
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
order_po.value = bookingPo;
}
const currentOrderItems = await loadOrderItems();
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
return true;
}
const bookingItems = Array.isArray(booking?.items) ? booking.items : [];
if (bookingItems.length === 0) {
return true;
+2 -2
View File
@@ -21,7 +21,7 @@ export const IS_DEV = parseBoolean(import.meta.env.VITE_IS_DEV, import.meta.env.
// Development mode
export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1);
export const API_URL = normalizeApiUrl(
import.meta.env.VITE_API_URL || (IS_DEV ? "https://api.truckwash.io:4433" : "https://api.truckwash.io")
import.meta.env.VITE_API_URL || (IS_DEV ? "/api" : "https://api.truckwash.io")
);
export const RELEASE_MANAGER_CONTROL_API_URL = normalizeApiUrl(
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_URL || API_URL
@@ -31,7 +31,7 @@ export const RELEASE_PUBLIC_GATEWAY_API_URL = normalizeApiUrl(
);
export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS ||
(IS_DEV ? "https://api.truckwash.io:4433,https://api.truckwash.io" : "")
(IS_DEV ? "https://api-v2.truckwash.io,https://api.truckwash.io" : "")
)
.split(",")
.map((url) => normalizeApiUrl(url.trim()))
+19 -1
View File
@@ -16,6 +16,24 @@ const normalizeReleaseChannelSlug = (value) =>
const normalizeBaseUrl = (value) => String(value || "").trim().replace(/\/+$/, "");
const browserOrigin = () => {
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return "http://localhost";
};
const resolveRuntimeBaseUrl = (value) => {
const baseUrl = normalizeBaseUrl(value);
if (!baseUrl) {
return "";
}
if (/^https?:\/\//i.test(baseUrl)) {
return baseUrl;
}
return new URL(`${baseUrl.replace(/^\/+/, "")}/`, `${browserOrigin()}/`).href.replace(/\/+$/, "");
};
const browserStorage = () => {
if (typeof window === "undefined") {
return null;
@@ -64,7 +82,7 @@ const buildRuntimeHeaders = () => {
export const runtimeApiUrl = (apiBaseUrl = API_URL, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
const selectedChannel = readSelectedReleaseChannel();
const selectedChannelApiBaseUrl = releaseChannelGatewayApiBaseUrl(selectedChannel, gatewayBaseUrl);
const runtimeBaseUrl = selectedChannelApiBaseUrl || normalizeBaseUrl(apiBaseUrl);
const runtimeBaseUrl = selectedChannelApiBaseUrl || resolveRuntimeBaseUrl(apiBaseUrl);
const url = new URL("release/runtime", `${runtimeBaseUrl}/`);
if (selectedChannel) {
url.searchParams.set("release_channel", selectedChannel);
+328 -3
View File
@@ -44,6 +44,7 @@ const releaseRuntimeStateMutable = reactive({
versions: {
frontend: null,
api: null,
service_set: null,
bundle_id: null,
bundle: null,
},
@@ -75,10 +76,16 @@ const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.
const normalizeRuntimeBaseUrl = (value) => {
const raw = String(value || "").trim().replace(/\/+$/, "");
if (!raw || !/^https?:\/\//i.test(raw)) {
if (!raw) {
return null;
}
return raw;
if (raw.startsWith("/") && !raw.startsWith("//")) {
return raw;
}
if (/^https?:\/\//i.test(raw)) {
return raw;
}
return null;
};
const runtimeUrls = (runtime = {}) => {
@@ -117,6 +124,7 @@ export const configureReleaseRuntime = (runtime = {}) => {
releaseRuntimeStateMutable.versions = {
frontend: runtime?.versions?.frontend || null,
api: runtime?.versions?.api || null,
service_set: runtime?.versions?.service_set || null,
bundle_id: runtime?.versions?.bundle_id || null,
bundle: runtime?.versions?.bundle || null,
};
@@ -334,6 +342,322 @@ const currentDeviceType = () => {
const versionLabel = (version) => version?.version_label || version?.label || null;
const commitSha = (version) => version?.commit_sha || version?.commit || null;
const RELEASE_SERVICE_DEFINITIONS = Object.freeze([
{ key: "frontend", label: "Frontend", kind: "app" },
{ key: "api", label: "API", kind: "app" },
{ key: "database", label: "Database", kind: "data" },
{ key: "redis", label: "Redis", kind: "data" },
{ key: "minio", label: "MinIO", kind: "data" },
]);
const RELEASE_MISSING_LABELS = Object.freeze({
release_bundle: "Release bundle",
frontend_version: "Frontend version",
frontend_base_url: "Frontend URL",
api_version: "API version",
api_base_url: "API URL",
database_service: "Database service",
redis_service: "Redis service",
minio_service: "MinIO service",
});
const isPlainRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
const firstFilledString = (...values) => {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
};
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
return {
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url),
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
};
};
const releaseRuntimeTraceId = (runtime = {}) => firstFilledString(runtime.traceId, runtime.trace_id);
const releaseRuntimeGeneratedAt = (runtime = {}) => firstFilledString(runtime.generatedAt, runtime.generated_at);
const releaseShortText = (value, length = 12) => {
const normalized = firstFilledString(value);
if (!normalized) {
return "";
}
return normalized.length > length ? normalized.slice(0, length) : normalized;
};
const releaseCommitValue = (version = null) => {
if (!isPlainRecord(version)) {
return "";
}
const commit = version.commit;
if (isPlainRecord(commit)) {
return firstFilledString(commit.sha, commit.commit_sha);
}
return firstFilledString(version.commit_sha, commit);
};
const releaseVersionPrimaryText = (version = null, fallback = "Missing version") => {
if (!isPlainRecord(version)) {
return fallback;
}
return firstFilledString(version.version_label, version.tag, releaseShortText(releaseCommitValue(version)), fallback);
};
const releaseVersionSecondaryText = (version = null) => {
if (!isPlainRecord(version)) {
return "";
}
const parts = [];
const commit = releaseShortText(releaseCommitValue(version));
const repository = firstFilledString(version.repository);
const branch = firstFilledString(version.branch);
if (commit && commit !== firstFilledString(version.version_label, version.tag)) {
parts.push(commit);
}
if (repository || branch) {
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
}
return parts.join(" - ");
};
const releaseStatusTone = (status = "") => {
const normalized = String(status || "").trim().toLowerCase();
if (
[
"failed",
"error",
"critical",
"service_unhealthy",
"unhealthy",
"degraded",
"reconcile_failed",
"restart_failed",
"provision_blocked",
].includes(normalized)
) {
return "danger";
}
if (
[
"missing",
"missing_value",
"not_configured",
"unconfigured",
"deployment_in_progress",
"warning",
"pending",
"queued",
"running",
"deploying",
"building",
"provisioning",
"unknown",
].includes(normalized)
) {
return "warning";
}
return "ok";
};
const releaseServiceStatus = (service = null, fallback = "connected") =>
firstFilledString(
service?.deployment_status,
service?.availability_state,
service?.status,
service?.state,
fallback
);
const releaseServicePrimaryText = (service = null) => {
if (!isPlainRecord(service)) {
return "";
}
const name = firstFilledString(
service.resource_name,
service.label,
service.coolify_service_uuid,
service.health_url,
service.repository
);
const id = Number(service.id || service.target_id || 0);
return [name, id > 0 ? `#${id}` : ""].filter(Boolean).join(" ");
};
const releaseServiceSecondaryText = (service = null) => {
if (!isPlainRecord(service)) {
return "";
}
const parts = [];
const resourceUuid = firstFilledString(service.resource_uuid);
const coolifyServiceUuid = firstFilledString(service.coolify_service_uuid);
const instanceLabel = firstFilledString(service.instance_label, service.coolify_instance_label);
const repository = firstFilledString(service.repository);
const branch = firstFilledString(service.branch);
const replication = isPlainRecord(service.replication) ? service.replication : null;
const replicationStatus = firstFilledString(replication?.last_status?.status, replication?.status);
if (resourceUuid) {
parts.push(`resource ${releaseShortText(resourceUuid)}`);
}
if (coolifyServiceUuid && coolifyServiceUuid !== resourceUuid) {
parts.push(`service ${releaseShortText(coolifyServiceUuid)}`);
}
if (repository || branch) {
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
}
if (instanceLabel) {
parts.push(instanceLabel);
}
if (replicationStatus) {
parts.push(`replication ${replicationStatus}`);
}
return parts.join(" - ");
};
const releaseServiceForKey = (serviceSet = null, key = "") => {
if (!isPlainRecord(serviceSet)) {
return null;
}
const stack = isPlainRecord(serviceSet.stack) ? serviceSet.stack : {};
const dataServices = isPlainRecord(serviceSet.data_services) ? serviceSet.data_services : {};
const targets = isPlainRecord(serviceSet.targets) ? serviceSet.targets : {};
const service = stack[key] || dataServices[key] || targets[key] || null;
return isPlainRecord(service) ? service : null;
};
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
const missing = Array.isArray(availability.missing)
? availability.missing.map((value) => String(value || "").trim()).filter(Boolean)
: [];
const missingLookup = new Set(missing);
const isDefaultChannel =
channel?.default_channel === true
|| channel?.default_channel === 1
|| String(channel?.slug || "").toLowerCase() === "stable";
const urls = releaseRuntimeUrlsForDisplay(runtime);
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
const bundleId = versions.bundle_id || bundle?.id || null;
const serviceSet = isPlainRecord(versions.service_set)
? versions.service_set
: isPlainRecord(bundle?.service_set)
? bundle.service_set
: null;
const defaultSharedLabel = "Default/shared runtime";
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
const buildAppRow = (key, label, version, url) => {
const missingVersionKey = `${key}_version`;
const missingUrlKey = `${key}_base_url`;
const missingKey = missingLookup.has(missingVersionKey)
? missingVersionKey
: missingLookup.has(missingUrlKey)
? missingUrlKey
: "";
const fallback = isDefaultChannel ? defaultSharedLabel : `Missing ${label} version`;
const status = missingKey
? "missing value"
: isDefaultChannel && !version && !url
? "shared"
: firstFilledString(version?.status, url ? "active" : "unknown");
return {
key,
label,
status,
tone: missingKey ? "warning" : releaseStatusTone(status),
primaryText: releaseVersionPrimaryText(version, fallback),
secondaryText: releaseVersionSecondaryText(version),
url: url || "",
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
.filter(Boolean)
.join(" - "),
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
};
};
const buildServiceRow = ({ key, label }) => {
const service = releaseServiceForKey(serviceSet, key);
const missingKey = missingLookup.has(`${key}_service`) ? `${key}_service` : "";
if (service) {
const status = releaseServiceStatus(service);
return {
key,
label,
status,
tone: releaseStatusTone(status),
primaryText: releaseServicePrimaryText(service) || "Connected service",
secondaryText: releaseServiceSecondaryText(service),
title: [releaseServicePrimaryText(service), releaseServiceSecondaryText(service), firstFilledString(service.health_url)]
.filter(Boolean)
.join(" - "),
missingLabel: "",
};
}
const fallbackText = isDefaultChannel
? defaultSharedLabel
: bundleId
? (serviceSet ? `Missing ${label} service` : "No service set connected")
: "Missing release bundle";
const status = isDefaultChannel ? "shared" : "missing";
return {
key,
label,
status,
tone: isDefaultChannel ? "ok" : "warning",
primaryText: fallbackText,
secondaryText: "",
title: fallbackText,
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : fallbackText,
};
};
return {
channelLabel: firstFilledString(channel?.name, channel?.slug, isDefaultChannel ? "Stable" : "Unknown channel"),
channelSlug: firstFilledString(channel?.slug),
traceId: releaseRuntimeTraceId(runtime) || "unknown",
generatedAt: releaseRuntimeGeneratedAt(runtime) || "unknown",
availabilityStatus: firstFilledString(availability.status, availability.configured === false ? "unconfigured" : "ready"),
availabilityTone: availability.configured === false || missing.length > 0
? "warning"
: releaseStatusTone(availability.status || "ready"),
bundleLabel: bundleId
? `#${bundleId}${firstFilledString(bundle?.version_label) ? ` ${bundle.version_label}` : ""}`
: defaultSharedLabel,
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
serviceSetLabel: serviceSet
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
: defaultSharedLabel,
missingLabels,
appRows: [
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
buildAppRow("api", "API", apiVersion, urls.api),
],
serviceRows: RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow),
};
};
export const buildCurrentReleaseHeaders = () => {
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown";
@@ -542,7 +866,7 @@ export const __resetReleaseTimelineForTests = () => {
releaseRuntimeStateMutable.traceId = "test-trace";
releaseRuntimeStateMutable.channel = null;
releaseRuntimeStateMutable.availableChannels = [];
releaseRuntimeStateMutable.versions = { frontend: null, api: null, bundle_id: null, bundle: null };
releaseRuntimeStateMutable.versions = { frontend: null, api: null, service_set: null, bundle_id: null, bundle: null };
releaseRuntimeStateMutable.frontendBaseUrl = null;
releaseRuntimeStateMutable.apiBaseUrl = null;
releaseRuntimeStateMutable.availability = {
@@ -557,6 +881,7 @@ export const __resetReleaseTimelineForTests = () => {
all_failure_metadata: true,
retention_days: 14,
};
releaseRuntimeStateMutable.generatedAt = null;
};
export const __setReleaseTimelineTransportForTests = (transport) => {
File diff suppressed because it is too large Load Diff
+85
View File
@@ -443,6 +443,50 @@ function createRequiredWarningsPosFixture() {
});
}
function createBookingPoDefaultPosFixture() {
const baseFixture = createPosFixture();
const customerNumber = 12345679;
const bookingId = 8891;
return createPosFixture({
preloadCreatedOrderItemsFromBooking: true,
orderBookingListStripsDetails: true,
orderBookingListStripsMetadata: true,
customerAttributesByNumber: {
[customerNumber]: [
...(baseFixture.customerAttributesByNumber[customerNumber] || []),
{
id: 88,
customer_number: customerNumber,
attribute: "usePONumbers",
},
],
},
orderBookings: [
{
id: bookingId,
customer_number: customerNumber,
customer_name: "(TEST) Pleno Vognmandsforretning",
department: 12,
datetime: "2026-05-21 09:30:00",
date: "2026-05-21",
reg_1: "BOOKPO1",
reg_2: "",
reg_3: "",
reference: "BOOKING-PO-REF",
reference_number: "BOOKING-PO-REF",
notes: "Booking PO note",
note: "Booking PO note",
po: "BOOKING-PO-DEFAULT",
pickup: false,
items: [{ id: 53, quantity: 1, price: 649 }],
order_id: null,
created_at: "2026-05-20 08:00:00",
},
],
});
}
function createReferenceAutocompletePosFixture() {
const baseFixture = createPosFixture();
const customerNumber = 12345679;
@@ -2304,6 +2348,47 @@ test.describe("Admin POS Orders - desktop step 1 customer and vehicle ownership"
await createOrderRequest;
});
test("applies booking PO defaults on desktop even when linked booking items are already loaded", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createBookingPoDefaultPosFixture(),
});
await primeOperatorSession(page, "pos-orders-booking-po-default-token");
await page.goto(POS_BOOT_URL);
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await page.locator("#reg_1").fill("BOOKPO1");
await selectStepOneCustomer(page, 12345679);
const createOrderRequest = waitForOrderMutation(
page,
"POST",
"/orders",
(body) => Number(body.booking_id) === 8891
);
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
const capturedCreateOrderRequest = await createOrderRequest;
expect(capturedCreateOrderRequest.postDataJSON()).toMatchObject({
booking_id: 8891,
po: "",
});
const stepTwo = page.getByTestId("pos-step-2");
await expect(stepTwo).toBeVisible();
await expect(stepTwo.getByTestId("pos-order-registration-1")).toContainText("BOOKPO1");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-reference")).toContainText("BOOKING-PO-REF");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po")).toContainText("BOOKING-PO-DEFAULT");
await expect(stepTwo.getByTestId("pos-order-metadata-note")).toContainText("Booking PO note");
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-control")).not.toHaveAttribute(
"data-warning-state",
"warning"
);
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-warning-icon")).toHaveCount(0);
});
test("preserves a manually selected required-reference customer and manual reference while editing reg_1", async ({
page,
}) => {
+5 -3
View File
@@ -1,6 +1,8 @@
import { test, expect } from "@playwright/test";
const PING_URL = /https:\/\/api\.truckwash\.io:4433\/ping(?:\?.*)?$/;
const PING_URL =
/(?:https:\/\/api\.truckwash\.io:4433|https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/api)\/ping(?:\?.*)?$/;
const CONNECTIVITY_PAGE_TIMEOUT_MS = 30_000;
function installPingFailure(page, onPing) {
return page.route(PING_URL, async (route) => {
@@ -22,7 +24,7 @@ test.describe("Connectivity issue", () => {
await page.goto("/connectivity-issue");
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
await expect(page.getByTestId("connectivity-icon")).toBeVisible();
await expect(page.getByTestId("connectivity-title")).toHaveText("Forbindelsesproblem");
await expect(page.getByTestId("connectivity-subtitle")).toContainText("internetforbindelse");
@@ -42,7 +44,7 @@ test.describe("Connectivity issue", () => {
});
await page.goto("/connectivity-issue");
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
await expect.poll(() => pingCount).toBeGreaterThan(0);
const initialPingCount = pingCount;
+132 -5
View File
@@ -99,6 +99,14 @@ function createReleaseState() {
coolify_service_uuid: "service-canary",
health_url: "https://canary.example.test/health",
auto_deploy: true,
deploy_context: {
coolify_public_url: "https://canary.example.test",
coolify_domain: "canary.example.test",
coolify_port: "443",
coolify_project_uuid: "project-main",
coolify_environment_name: "canary",
coolify_server_uuid: "server-main",
},
},
],
serviceSets: [
@@ -118,6 +126,18 @@ function createReleaseState() {
app: "frontend",
repository: "truckwash/front-end-vue",
branch: DEFAULT_RELEASE_BRANCH,
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "service-canary",
health_url: "https://canary.example.test/health",
deploy_context: {
coolify_public_url: "https://canary.example.test",
coolify_domain: "canary.example.test",
coolify_port: "443",
coolify_project_uuid: "project-main",
coolify_environment_name: "canary",
coolify_server_uuid: "server-main",
},
},
api: null,
},
@@ -126,22 +146,52 @@ function createReleaseState() {
id: 10,
kind: "database",
label: "Canary MariaDB",
resource_name: "mariadb-canary",
resource_uuid: "mariadb-canary-service",
instance_label: "Production Coolify",
deployment_status: "running",
replication: { id: 100, label: "mariadb-canary", status: "ok", role: "replica" },
replication: {
id: 100,
label: "mariadb-canary",
status: "ok",
role: "replica",
host: "db-canary.example.test",
port: 3306,
},
},
redis: {
id: 11,
kind: "redis",
label: "Canary Redis",
resource_name: "redis-canary",
resource_uuid: "redis-canary-service",
instance_label: "Production Coolify",
deployment_status: "running",
replication: { id: 101, label: "redis-canary", status: "ok", role: "replica" },
replication: {
id: 101,
label: "redis-canary",
status: "ok",
role: "replica",
host: "redis-canary.example.test",
port: 6379,
},
},
minio: {
id: 12,
kind: "minio",
label: "Canary MinIO",
resource_name: "minio-canary",
resource_uuid: "minio-canary-service",
instance_label: "Production Coolify",
deployment_status: "running",
replication: { id: 102, label: "minio-canary", status: "ok", role: "replica" },
replication: {
id: 102,
label: "minio-canary",
status: "ok",
role: "replica",
host: "minio-canary.example.test",
port: 9000,
},
},
},
attached_bundles: [],
@@ -1061,6 +1111,20 @@ async function installReleaseMocks(page, state) {
return;
}
if (/\/superuser\/releases\/targets\/\d+$/.test(pathname) && method === "DELETE") {
const id = Number(pathname.match(/targets\/(\d+)/)?.[1] || 0);
state.targets = state.targets.filter((entry) => Number(entry.id) !== id);
for (const serviceSet of state.serviceSets) {
for (const app of ["frontend", "api"]) {
if (Number(serviceSet.targets?.[app]?.id) === id) {
serviceSet.targets[app] = null;
}
}
}
await route.fulfill(json({ data: { id, removed: true, provider_resources_deleted: false } }));
return;
}
if (pathname.endsWith("/superuser/releases/service-sets") && method === "GET") {
await route.fulfill(json({ data: summary(state).service_sets }));
return;
@@ -1371,7 +1435,18 @@ test("overview status dashboard prioritizes release issues and opens channel det
const dashboard = page.getByTestId("release-status-dashboard");
await expect(dashboard).toBeVisible({ timeout: 90_000 });
await expect(dashboard).toContainText("Release promotion is blocked");
await expect(dashboard.getByTestId("release-status-summary")).toContainText("Critical");
await expect(page.getByTestId("release-cockpit-channel-list")).toContainText("Canary");
await expect(page.getByTestId("release-cockpit-create-release")).toBeVisible();
const controlApi = page.getByTestId("release-control-api");
await expect(controlApi).toContainText("Control API");
await expect(controlApi.locator(".release-control-plane")).toBeHidden();
await controlApi.locator("summary").click();
await expect(controlApi.locator(".release-control-plane")).toBeVisible();
await controlApi.locator("summary").click();
await expect(controlApi.locator(".release-control-plane")).toBeHidden();
const issuePanel = dashboard.getByTestId("release-status-issues");
const failedDeploymentIssue = issuePanel.getByRole("button").filter({ hasText: "Composer install failed" }).first();
@@ -1381,6 +1456,12 @@ test("overview status dashboard prioritizes release issues and opens channel det
expect(issueBox).toBeTruthy();
expect(issueBox.y).toBeLessThan((viewport?.height || 900) - 24);
await page.getByTestId("release-cockpit-primary-action").click();
await expect(page.getByTestId("release-bundle-flow")).toBeVisible();
await expect(page.getByTestId("release-status-drawer")).toBeVisible();
await page.locator(".release-status-drawer .delete").click();
await selectReleaseTab(page, "Overview");
const canaryCard = page.getByTestId("release-status-channel-card").filter({ hasText: "Canary" }).first();
await expect(canaryCard).toContainText("Frontend");
await expect(canaryCard).toContainText("API");
@@ -1650,6 +1731,15 @@ test("superusers manage release channels, assignments, deployments, and replay",
.locator("tbody tr")
.filter({ hasText: "truckwash/backend-php#release/canary" })
.first();
await apiTargetRow.locator('[data-testid^="release-target-actions-"] button').first().click();
await expect(apiTargetRow.getByRole("button", { name: "Delete" })).toBeVisible();
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("Coolify service is not deleted");
dialog.dismiss();
});
await apiTargetRow.getByRole("button", { name: "Delete" }).click();
await expect(apiTargetRow).toContainText("truckwash/backend-php#release/canary");
await page.keyboard.press("Escape");
await apiTargetRow.getByRole("button", { name: "Redeploy" }).click();
const redeployDate = new Date().toISOString().slice(0, 10);
const savedApiTarget = state.targets.find((target) => target.repository === "truckwash/backend-php");
@@ -1673,6 +1763,27 @@ test("superusers manage release channels, assignments, deployments, and replay",
await expect(redeployRow).toContainText("latestcommit");
await expect(page.getByTestId("release-service-set-cards")).toContainText("Canary shared stack");
const bundleFlow = page.getByTestId("release-bundle-flow");
await expect(bundleFlow.getByTestId("release-dataset-mode-options")).toContainText("Reuse existing services");
await expect(bundleFlow.getByTestId("release-apply-preview")).toContainText("Apply Preview");
await expect(bundleFlow.getByTestId("release-apply-steps")).toContainText("Check GitHub access");
const serviceActionTable = bundleFlow.getByTestId("release-service-action-table");
await expect(serviceActionTable).toContainText("Frontend");
await expect(serviceActionTable).toContainText("PHP API");
await expect(serviceActionTable).toContainText("MariaDB");
await expect(serviceActionTable).toContainText("canary.example.test:443");
await expect(serviceActionTable).toContainText("api-v2.truckwash.io:443");
await expect(serviceActionTable).toContainText("db-canary.example.test:3306");
await expect(serviceActionTable).toContainText("redis-canary.example.test:6379");
await expect(serviceActionTable).toContainText("minio-canary.example.test:9000");
await expect(bundleFlow.getByTestId("release-bundle-risk-warning").first()).toContainText("Attaching existing data");
await bundleFlow.getByTestId("release-dataset-mode-clone_existing").click();
await expect(serviceActionTable).toContainText("Clone replica from source");
await expect(serviceActionTable).toContainText("host/port assigned after replica provisioning");
await expect(bundleFlow.getByTestId("release-apply-steps")).toContainText("plan replica clones");
await bundleFlow.getByTestId("release-dataset-mode-fresh_empty").click();
await expect(serviceActionTable).toContainText("Create/register empty service");
await expect(serviceActionTable).toContainText("host/port assigned after service is configured");
await bundleFlow.getByTestId("release-dataset-mode-attach_existing").click();
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(bundleFlow.getByPlaceholder("owner/frontend")).toHaveValue("truckwash/front-end-vue");
await expect(bundleFlow.getByPlaceholder("owner/backend-php")).toHaveValue("truckwash/backend-php");
@@ -1818,12 +1929,24 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
const bundleChannel = bundleFlow.getByTestId("release-bundle-channel");
await bundleChannel.selectOption("2");
await expect(bundleChannel).toHaveValue("2");
await bundleFlow.getByTestId("release-bundle-dataset-mode").selectOption("isolated_stack");
await bundleFlow.getByTestId("release-dataset-mode-isolated_stack").click();
await bundleChannel.selectOption("2");
await expect(bundleChannel).toHaveValue("2");
await expect(bundleFlow).toContainText("without attaching production data");
await expect(bundleFlow.getByTestId("release-bundle-source-service-set")).toBeDisabled();
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
await expect(bundleFlow.getByTestId("release-apply-preview")).toContainText("Create isolated stack");
await expect(bundleFlow.getByTestId("release-apply-steps")).toContainText("new Coolify frontend/API targets");
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
"release-internal-safe-stack-frontend"
);
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("release-internal-safe-stack-api");
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
"release-internal-safe-stack-database"
);
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
"created by Coolify; host/port assigned after deployment"
);
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(bundleFlow.getByPlaceholder("owner/frontend", { exact: true })).toHaveValue("truckwash/front-end-vue");
@@ -1877,7 +2000,11 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount);
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount);
page.once("dialog", (dialog) => dialog.accept());
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("soft-deleted");
expect(dialog.message()).toContain("Coolify provider resources are not deleted");
dialog.accept();
});
await page.getByTestId(`release-service-set-delete-${isolatedSet.id}`).click();
await expect(page.getByTestId(`release-service-set-delete-${isolatedSet.id}`)).toHaveCount(0);
expect(state.serviceSets.find((set) => Number(set.id) === Number(isolatedSet.id))).toBeUndefined();
+151
View File
@@ -0,0 +1,151 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-allow-headers":
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
body: JSON.stringify(body),
});
const releaseRuntime = () => ({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-session-release-e2e",
channel: {
id: 2,
slug: "canary",
name: "Canary",
enabled: true,
default_channel: false,
},
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
repository: "truckwash/backend-php",
branch: "release/canary",
status: "deployed",
},
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" },
redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" },
minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" },
},
},
bundle_id: 31,
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
urls: {
frontend_base_url: "http://127.0.0.1:5173/canary/frontend",
api_base_url: "/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
},
capture_policy: {
enabled: false,
capture_level: "metadata",
all_failure_metadata: true,
retention_days: 14,
},
});
async function openHiddenRuntimeMenu(page: Page) {
for (let attempt = 0; attempt < 3; attempt += 1) {
await page.keyboard.press("Shift");
await page.keyboard.press("Shift");
await page.keyboard.press("Shift");
if (
await page
.getByTestId("request-queue-runtime-box")
.isVisible({ timeout: 1000 })
.catch(() => false)
) {
break;
}
}
await expect(page.getByTestId("request-queue-runtime-box")).toBeVisible();
}
async function bootAuthenticatedSession(page: Page) {
const runtime = releaseRuntime();
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
window.localStorage.setItem(
"release_channel_switch_notice_seen",
JSON.stringify({ "user:77:canary": "2026-05-19T09:30:00.000Z" })
);
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
id: 77,
customer_number: 990077,
display_name: "Release Inspector",
runtime_config: {
release: runtime,
},
},
});
await page.route("**/release/runtime**", async (route) => {
await route.fulfill(json({ data: runtime }));
});
await primeMockSession(page, { token: "session-release-runtime-token", bootPath: "/user" });
}
test.describe("session release runtime inspector", () => {
test.beforeEach(async ({ page }, testInfo: TestInfo) => {
test.skip(!isDesktopProject(testInfo), "Hidden runtime menu coverage is desktop-focused.");
await bootAuthenticatedSession(page);
});
test("shows the active release and connected services in the Shift menu", async ({ page }) => {
await openHiddenRuntimeMenu(page);
const runtimeBox = page.getByTestId("request-queue-runtime-box");
await expect(runtimeBox).toContainText("Session release");
await expect(runtimeBox).toContainText("Canary");
await expect(runtimeBox).toContainText("trace-session-release-e2e");
await expect(runtimeBox).toContainText("#31 canary-bundle");
await expect(runtimeBox).toContainText("Canary isolated stack");
await expect(page.getByTestId("request-queue-release-app-frontend")).toContainText("frontend-canary");
await expect(page.getByTestId("request-queue-release-app-frontend")).toContainText(
"http://127.0.0.1:5173/canary/frontend"
);
await expect(page.getByTestId("request-queue-release-app-api")).toContainText("api-canary");
await expect(page.getByTestId("request-queue-release-app-api")).toContainText("/api");
await expect(page.getByTestId("request-queue-release-service-frontend")).toContainText("canary-frontend #51");
await expect(page.getByTestId("request-queue-release-service-api")).toContainText("canary-api #52");
await expect(page.getByTestId("request-queue-release-service-database")).toContainText("canary-db #54");
await expect(page.getByTestId("request-queue-release-service-redis")).toContainText("canary-redis #55");
await expect(page.getByTestId("request-queue-release-service-minio")).toContainText("canary-minio #56");
await expect(runtimeBox).toContainText("Runtime details");
await expect(page.getByTestId("request-queue-i18n-catalog-switch")).toBeVisible();
});
});
+80 -8
View File
@@ -184,6 +184,41 @@ function normalizePositiveIntegerValue(value) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function normalizeOrderPoValue(value) {
return String(value ?? "").trim();
}
function findPosOrderBookingById(posFixture, bookingId) {
const normalizedBookingId = normalizePositiveIntegerValue(bookingId);
if (!normalizedBookingId) {
return null;
}
return (posFixture.orderBookings || []).find((booking) => Number(booking?.id || 0) === normalizedBookingId) || null;
}
function getPosOrderBookingPo(posFixture, bookingId) {
const booking = findPosOrderBookingById(posFixture, bookingId);
const bookingPo = normalizeOrderPoValue(booking?.po);
return bookingPo || "";
}
function applyBookingPoDefaultToMockOrder(posFixture, order) {
if (!order || normalizeOrderPoValue(order.po) !== "") {
return order;
}
const bookingPo = getPosOrderBookingPo(posFixture, order.booking_id);
if (!bookingPo) {
return order;
}
return {
...order,
po: bookingPo,
};
}
function normalizeReferenceKey(value) {
return String(value ?? "")
.trim()
@@ -638,7 +673,7 @@ function parseFilterExpressions(filters) {
}, {});
}
function toOrderBookingListEntry(booking, stripDetails = false) {
function toOrderBookingListEntry(booking, stripDetails = false, options = {}) {
if (!stripDetails || !booking || typeof booking !== "object") {
return booking;
}
@@ -646,6 +681,13 @@ function toOrderBookingListEntry(booking, stripDetails = false) {
const summaryBooking = { ...booking };
delete summaryBooking.items;
delete summaryBooking.parsed_services;
if (options.stripMetadata === true) {
delete summaryBooking.reference;
delete summaryBooking.reference_number;
delete summaryBooking.notes;
delete summaryBooking.note;
delete summaryBooking.po;
}
return summaryBooking;
}
@@ -3092,7 +3134,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
json({
success: true,
data: bookings.map((booking) =>
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true)
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true, {
stripMetadata: posFixture.orderBookingListStripsMetadata === true,
})
),
})
);
@@ -3422,28 +3466,53 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
if (pathname.endsWith("/orders") && method === "POST") {
const body = request.postDataJSON?.() || {};
const orderId = posFixture.nextOrderId++;
const bookingId = normalizePositiveIntegerValue(body.booking_id);
const orderPo = normalizeOrderPoValue(body.po) || getPosOrderBookingPo(posFixture, bookingId);
posFixture.ordersById[orderId] = {
id: orderId,
customer_id: Number(body.customer_id),
department_id: Number(body.department_id || body.department || 12),
reference: body.reference || "",
po: body.po || "",
po: orderPo,
safety_seal: normalizeSafetySealValue(body.safety_seal),
notes: body.notes || "",
reg_1: normalizeRegistrationValue(body.reg_1),
reg_2: normalizeRegistrationValue(body.reg_2),
reg_3: normalizeRegistrationValue(body.reg_3),
invoice_collection_id: null,
booking_id: normalizePositiveIntegerValue(body.booking_id),
booking_id: bookingId,
completed_at: null,
closed_at: null,
created_at: normalizeCreatedAtValue(body.created_at) || toSqlDateTime(),
include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice),
};
posFixture.orderItemsByOrderId[orderId] = [];
const booking = findPosOrderBookingById(posFixture, bookingId);
const bookingItems =
posFixture.preloadCreatedOrderItemsFromBooking && Array.isArray(booking?.items) ? booking.items : [];
posFixture.orderItemsByOrderId[orderId] = bookingItems
.map((bookingItem) => {
const productId = Number(bookingItem?.id || 0);
const product = (posFixture.products || []).find((entry) => Number(entry.id) === productId);
if (!product) {
return null;
}
return buildPosOrderItem(
product,
{
order_id: orderId,
product_id: productId,
quantity: bookingItem?.quantity || 1,
price: bookingItem?.price ?? product.price,
notes: bookingItem?.notes || "",
},
posFixture.nextOrderItemId++
);
})
.filter(Boolean);
posFixture.economicModuleOrdersByOrderId[orderId] = { invoice_id: null, invoice_draft_id: null };
posFixture.stripeModuleOrdersByOrderId[orderId] = {};
await route.fulfill(json({ success: true, data: { id: orderId } }));
await route.fulfill(json({ success: true, data: { id: orderId, po: orderPo } }));
return true;
}
@@ -3461,7 +3530,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
body
);
if (posFixture.ordersById[orderId]) {
posFixture.ordersById[orderId] = {
const updatedOrder = {
...posFixture.ordersById[orderId],
...body,
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
@@ -3483,6 +3552,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
: {}),
};
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
}
if (shouldRegenerateWashCertificate) {
replaceWashCertificateAttachment(posFixture, orderId);
@@ -3512,8 +3582,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
: body.field === "created_at"
? normalizeCreatedAtValue(body.value)
: body.value;
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, posFixture.ordersById[orderId]);
} else {
posFixture.ordersById[orderId] = {
const updatedOrder = {
...posFixture.ordersById[orderId],
...body,
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
@@ -3535,6 +3606,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
: {}),
};
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
}
}
if (shouldRegenerateWashCertificate) {
+4
View File
@@ -38,6 +38,10 @@ describe("release bootstrap", () => {
);
});
it("resolves local dev API runtime requests against the current origin", () => {
expect(runtimeApiUrl("/api")).toBe(`${window.location.origin}/api/release/runtime`);
});
it("attaches release trace, channel, and frontend headers to runtime requests", async () => {
localStorage.setItem("release_trace_id", "trace-runtime");
localStorage.setItem("release_channel_selected_slug", "Internal");
+148
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetReleaseTimelineForTests,
__setReleaseTimelineTransportForTests,
buildReleaseSessionSummary,
buildCurrentReleaseHeaders,
buildReleaseTimelineContext,
configureReleaseRuntime,
@@ -45,6 +46,131 @@ describe("release timeline runtime", () => {
expect(releaseRuntimeState.versions.bundle_id).toBe(31);
});
it("builds display rows for the active release bundle and connected services", () => {
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-session-release",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
repository: "truckwash/backend-php",
branch: "release/canary",
status: "deployed",
},
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
database: {
id: 54,
resource_name: "canary-db",
resource_uuid: "database-resource-uuid",
deployment_status: "ready",
},
redis: {
id: 55,
resource_name: "canary-redis",
resource_uuid: "redis-resource-uuid",
deployment_status: "ready",
},
minio: {
id: 56,
resource_name: "canary-minio",
resource_uuid: "minio-resource-uuid",
availability_state: "healthy",
},
},
},
bundle_id: 31,
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: { configured: true, missing: [], status: "ready" },
});
const summary = buildReleaseSessionSummary();
expect(releaseRuntimeState.versions.service_set.name).toBe("Canary isolated stack");
expect(summary.channelLabel).toBe("Canary");
expect(summary.traceId).toBe("trace-session-release");
expect(summary.bundleLabel).toBe("#31 canary-bundle");
expect(summary.serviceSetLabel).toBe("Canary isolated stack");
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
primaryText: "frontend-canary",
url: "https://api-v2.truckwash.io/canary/frontend",
tone: "ok",
});
expect(summary.appRows.find((row) => row.key === "api")?.secondaryText).toContain("feedface0000");
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
primaryText: "canary-db #54",
tone: "ok",
});
expect(summary.serviceRows.find((row) => row.key === "redis")?.secondaryText).toContain("redis-resour");
expect(summary.serviceRows.find((row) => row.key === "minio")?.status).toBe("healthy");
});
it("marks stable sessions without release bundle data as shared runtime", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-stable",
channel: { slug: "stable", name: "Stable", default_channel: true },
versions: { frontend: null, api: null, bundle_id: null, bundle: null },
availability: { configured: true, missing: [], status: "ready" },
});
expect(summary.bundleLabel).toBe("Default/shared runtime");
expect(summary.serviceSetLabel).toBe("Default/shared runtime");
expect(summary.appRows.every((row) => row.primaryText === "Default/shared runtime")).toBe(true);
expect(summary.appRows.every((row) => row.tone === "ok")).toBe(true);
expect(summary.serviceRows.every((row) => row.primaryText === "Default/shared runtime")).toBe(true);
expect(summary.serviceRows.every((row) => row.tone === "ok")).toBe(true);
});
it("surfaces non-default release runtime missing values as warnings", () => {
const summary = buildReleaseSessionSummary({
trace_id: "trace-internal",
channel: { slug: "internal", name: "Internal", default_channel: false },
versions: {
frontend: null,
api: { version_label: "api-internal" },
bundle_id: null,
bundle: null,
},
availability: {
configured: false,
missing: ["release_bundle", "frontend_version", "api_base_url"],
status: "unconfigured",
},
});
expect(summary.missingLabels).toEqual(["Release bundle", "Frontend version", "API URL"]);
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
tone: "warning",
missingLabel: "Frontend version",
});
expect(summary.appRows.find((row) => row.key === "api")).toMatchObject({
tone: "warning",
missingLabel: "API URL",
});
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
tone: "warning",
primaryText: "Missing release bundle",
});
});
it("uses release runtime frontend and API URLs when provided", () => {
configureReleaseRuntime({
trace_id: "trace-urls",
@@ -66,6 +192,28 @@ describe("release timeline runtime", () => {
expect(resolveReleaseApiUrl("/orders")).toBe("https://api-v2.truckwash.io/canary/api/orders");
});
it("keeps same-origin release API URLs visible in the session summary", () => {
configureReleaseRuntime({
trace_id: "trace-local-api",
channel: { slug: "canary", name: "Canary" },
versions: {
frontend: { version_label: "frontend-canary" },
api: { version_label: "api-canary" },
bundle_id: 31,
},
urls: {
api_base_url: "/api/",
},
});
const summary = buildReleaseSessionSummary();
expect(releaseRuntimeState.apiBaseUrl).toBe("/api");
expect(getReleaseRuntimeApiBaseUrl()).toBe("/api");
expect(resolveReleaseApiUrl("/orders")).toBe("/api/orders");
expect(summary.appRows.find((row) => row.key === "api")?.url).toBe("/api");
});
it("builds release headers from the active runtime state", () => {
configureReleaseRuntime({
trace_id: "trace-headers",
+64
View File
@@ -5,6 +5,7 @@ import axios from "axios";
import RequestQueueProgress from "@/components/global/RequestQueueProgress.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { API_URL } from "@/config.js";
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
import {
__configureRequestQueueForTests,
__resetRequestQueueForTests,
@@ -62,6 +63,7 @@ describe("RequestQueueProgress", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
__resetReleaseTimelineForTests();
__resetRequestQueueForTests();
__configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 });
vi.stubGlobal(
@@ -73,6 +75,7 @@ describe("RequestQueueProgress", () => {
afterEach(() => {
__resetRequestQueueForTests();
__resetReleaseTimelineForTests();
resetSessionUserState();
localStorage.clear();
vi.restoreAllMocks();
@@ -253,6 +256,67 @@ describe("RequestQueueProgress", () => {
expect(runtimeBox.text()).toContain("Ingoing bandwidth");
});
it("shows the active session release bundle, app URLs, and connected services", async () => {
configureReleaseRuntime({
generated_at: "2026-05-19T09:30:00.000Z",
trace_id: "trace-request-panel",
channel: { slug: "canary", name: "Canary", default_channel: false },
versions: {
frontend: {
version_label: "frontend-canary",
commit_sha: "c0ffee0000001111222233334444555566667777",
repository: "truckwash/front-end-vue",
branch: "release/canary",
status: "deployed",
},
api: {
version_label: "api-canary",
commit_sha: "feedface00001111222233334444555566667777",
repository: "truckwash/backend-php",
branch: "release/canary",
status: "deployed",
},
service_set: {
id: 53,
name: "Canary isolated stack",
stack: {
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" },
redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" },
minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" },
},
},
bundle_id: 31,
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
urls: {
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
api_base_url: "https://api-v2.truckwash.io/canary/api",
},
availability: { configured: true, missing: [], status: "ready" },
});
const wrapper = mount(RequestQueueProgress);
await triggerShiftTriplePress();
await flushManyMicrotasks();
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
expect(runtimeBox.text()).toContain("Session release");
expect(runtimeBox.text()).toContain("Canary");
expect(runtimeBox.text()).toContain("trace-request-panel");
expect(runtimeBox.text()).toContain("#31 canary-bundle");
expect(runtimeBox.text()).toContain("Canary isolated stack");
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain("frontend-canary");
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain(
"https://api-v2.truckwash.io/canary/frontend"
);
expect(wrapper.get("[data-testid='request-queue-release-app-api']").text()).toContain("api-canary");
expect(wrapper.get("[data-testid='request-queue-release-service-database']").text()).toContain("canary-db #54");
expect(wrapper.get("[data-testid='request-queue-release-service-redis']").text()).toContain("canary-redis #55");
expect(wrapper.get("[data-testid='request-queue-release-service-minio']").text()).toContain("canary-minio #56");
});
it("shows subuser details in the user box", async () => {
SessionUser.isSubuser.value = true;
SessionUser.subuser.name.value = "Sub User";
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { createApiProxyOptions } from "../../vite.config.js";
describe("Vite API proxy", () => {
it("forwards local /api requests to the working dev API by default", () => {
const options = createApiProxyOptions({});
expect(options.target).toBe("https://api.truckwash.io:4433");
expect(options.changeOrigin).toBe(true);
expect(options.secure).toBe(false);
expect(options.rewrite("/api/ping")).toBe("/ping");
expect(options.rewrite("/api/release/runtime")).toBe("/release/runtime");
expect(options.rewrite("/api")).toBe("/");
});
it("allows a local gateway override", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "http://localhost",
});
expect(options.target).toBe("http://localhost");
expect(options.rewrite("/api/ping")).toBe("/ping");
});
it("allows disabling prefix stripping for compatible local gateways", () => {
const options = createApiProxyOptions({
VITE_API_PROXY_TARGET: "http://localhost",
VITE_API_PROXY_STRIP_PREFIX: "false",
});
expect(options.target).toBe("http://localhost");
expect(options.rewrite).toBeUndefined();
});
});
+18
View File
@@ -94,6 +94,21 @@ function releaseEntryManifest() {
}
}
export function createApiProxyOptions(env = process.env) {
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
return {
target: env.VITE_API_PROXY_TARGET || 'https://api.truckwash.io:4433',
changeOrigin: true,
secure: false,
...(stripPrefix
? {
rewrite: (requestPath) => requestPath.replace(/^\/api(?=\/|$)/, '') || '/'
}
: {})
}
}
export default defineConfig(({ mode }) => {
const isProd = mode === 'production'
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
@@ -259,6 +274,9 @@ export default defineConfig(({ mode }) => {
isolate: true
},
server: {
proxy: {
'/api': createApiProxyOptions()
},
watch: {
ignored: ['**/output/playwright/**', '**/node_modules.codex-backup/**']
}