Files
pleno-vue/src/services/releaseChannelAvailability.js
T
Jeppe Bundgaard ebcd52a019 Add endpoint mode options, release channel rollback handling, and runtime refresh improvements
- Implement `endpoint_mode` (manual/auto) and related configuration in i18n across multiple locales.
- Add new utility methods for channel rollback and runtime confirmation workflows.
- Update unit and E2E tests to cover runtime refresh behavior, channel switching mechanics, and release references.
- Enhance `SessionUser` and related services to improve error handling during release runtime refresh.
- Add data-test attributes for frontend components to support improved test coverage.
2026-05-21 14:47:46 +02:00

575 lines
18 KiB
JavaScript

import { computed, reactive, readonly } from "vue";
import { configureReleaseRuntime, releaseRuntimeState } from "@/services/releaseTimeline.js";
export const RELEASE_CHANNEL_IGNORE_STORAGE_KEY = "release_channel_unavailable_ignore_until";
export const RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY = "release_channel_switch_notice_seen";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
export const RELEASE_CHANNEL_IGNORE_MS = 5 * 60 * 1000;
export const RELEASE_CHANNEL_CHECK_INTERVAL_MS = 10 * 1000;
const readIgnoreMap = () => {
if (typeof window === "undefined") {
return {};
}
try {
const parsed = JSON.parse(window.localStorage.getItem(RELEASE_CHANNEL_IGNORE_STORAGE_KEY) || "{}");
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
};
const writeIgnoreMap = (map) => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(RELEASE_CHANNEL_IGNORE_STORAGE_KEY, JSON.stringify(map));
} catch {
// Ignore storage failures; the guard will simply remain active.
}
};
const readSwitchNoticeMap = () => {
if (typeof window === "undefined") {
return {};
}
try {
const parsed = JSON.parse(window.localStorage.getItem(RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY) || "{}");
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
};
const writeSwitchNoticeMap = (map) => {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY, JSON.stringify(map));
} catch {
// Ignore storage failures; the notice can be shown again on the next load.
}
};
const normalizeReleaseChannelSlug = (value) => {
const slug = String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug.slice(0, 64);
};
const readSelectedChannelSlug = () => {
if (typeof window === "undefined") {
return "";
}
try {
return normalizeReleaseChannelSlug(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
} catch {
return "";
}
};
const writeSelectedChannelSlug = (slug) => {
if (typeof window === "undefined") {
return;
}
try {
if (slug) {
window.localStorage.setItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY, slug);
} else {
window.localStorage.removeItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY);
}
} catch {
// Storage is optional; the runtime endpoint will fall back to the assigned channel.
}
};
const state = reactive({
now: Date.now(),
ignoredUntilByChannel: readIgnoreMap(),
switchNoticeSeenByKey: readSwitchNoticeMap(),
switchNoticePrincipalKey: "",
selectedChannelSlug: readSelectedChannelSlug(),
});
let clockTimer = null;
export const releaseChannelAvailabilityClock = readonly(state);
export const setReleaseChannelSwitchNoticePrincipal = (principalKey) => {
state.switchNoticePrincipalKey = String(principalKey || "").trim();
};
export const releaseChannelKey = (channel) => {
const slug = normalizeReleaseChannelSlug(channel?.slug || "");
if (slug) {
return slug;
}
const id = String(channel?.id || "").trim();
return id ? `id:${id}` : "";
};
const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
const hasExplicitReleaseRuntime = (runtime) => {
return (
Boolean(runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false) ||
hasOwn(runtime, "versions")
);
};
const runtimeAvailability = (runtime, channel) => {
if (runtime?.availability && typeof runtime.availability === "object") {
const missing = Array.isArray(runtime.availability.missing) ? runtime.availability.missing : [];
const configured =
typeof runtime.availability.configured === "boolean"
? runtime.availability.configured
: missing.length === 0 && runtime.availability.status !== "unconfigured";
return {
configured,
missing,
status: runtime.availability.status || (configured ? "ready" : "unconfigured"),
};
}
if (!hasOwn(runtime, "versions")) {
return {
configured: true,
missing: [],
status: "ready",
};
}
const isDefaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
if (isDefaultChannel) {
return {
configured: true,
missing: [],
status: "ready",
};
}
const versions = runtime?.versions || {};
const missing = [];
if (!versions.bundle_id) {
missing.push("release_bundle");
}
if (!versions.frontend) {
missing.push("frontend_version");
} else if (!(runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || runtime?.frontendBaseUrl)) {
missing.push("frontend_base_url");
}
if (!versions.api) {
missing.push("api_version");
} else if (!(runtime?.api_base_url || runtime?.urls?.api_base_url || runtime?.apiBaseUrl)) {
missing.push("api_base_url");
}
return {
configured: missing.length === 0,
missing,
status: missing.length === 0 ? "ready" : "unconfigured",
};
};
const channelDisplayName = (channel, fallback = "Release channel") => {
const slug = String(channel?.slug || "").trim();
return String(channel?.name || slug || fallback).trim();
};
const normalizeReleaseChannelOption = (entry = {}, runtime = {}) => {
const channel = entry?.channel && typeof entry.channel === "object" ? entry.channel : entry;
const channelSlug = normalizeReleaseChannelSlug(channel?.slug || "");
const channelName = channelDisplayName(channel);
const defaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || channelSlug === "stable";
const versions =
entry?.versions ||
(releaseChannelKey(channel) === releaseChannelKey(runtime?.channel) ? runtime?.versions || {} : {});
const availability =
entry?.availability && typeof entry.availability === "object"
? entry.availability
: runtimeAvailability({ channel, versions }, channel);
return {
channel,
channelSlug,
channelName,
defaultChannel,
description: channel?.description || "",
versions,
availability,
configured: availability.configured !== false,
missing: Array.isArray(availability.missing) ? availability.missing : [],
status: availability.status || (availability.configured === false ? "unconfigured" : "ready"),
};
};
export const getReleaseChannelOptions = (runtime = {}) => {
const source = Array.isArray(runtime?.availableChannels)
? runtime.availableChannels
: Array.isArray(runtime?.available_channels)
? runtime.available_channels
: [];
const entries = source.length > 0 ? source : runtime?.channel ? [{ channel: runtime.channel }] : [];
const optionsByKey = new Map();
entries.forEach((entry) => {
const option = normalizeReleaseChannelOption(entry, runtime);
const key = releaseChannelKey(option.channel);
if (key && !optionsByKey.has(key)) {
optionsByKey.set(key, option);
}
});
if (runtime?.channel) {
const currentOption = normalizeReleaseChannelOption(
{
channel: runtime.channel,
versions: runtime.versions || {},
availability: runtime.availability || null,
},
runtime
);
const key = releaseChannelKey(currentOption.channel);
if (key && !optionsByKey.has(key)) {
optionsByKey.set(key, currentOption);
}
}
return [...optionsByKey.values()];
};
export const hasSelectableReleaseChannels = (runtime = {}) => {
const options = getReleaseChannelOptions(runtime);
const hasOnlyDefaultStable =
options.length === 1 && options[0].defaultChannel === true && options[0].channelSlug === "stable";
return options.length > 1 && !hasOnlyDefaultStable;
};
export const getReleaseChannelUnavailableStatus = (runtime = {}, now = Date.now(), ignoredUntil = 0) => {
const channel = runtime?.channel || null;
const channelSlug = normalizeReleaseChannelSlug(channel?.slug || "");
const channelName = channelDisplayName(channel);
const isDefaultChannel =
channel?.default_channel === true || channel?.default_channel === 1 || channelSlug === "stable";
const explicitAvailability = hasExplicitReleaseRuntime(runtime);
const availability = runtimeAvailability(runtime, channel);
const ignored = Number(ignoredUntil || 0) > now;
const unavailable = Boolean(channelSlug) && !isDefaultChannel && availability.configured === false;
return {
channel,
channelSlug,
channelName,
defaultChannel: isDefaultChannel,
explicitAvailability,
description: channel?.description || "",
missing: availability.missing,
configured: availability.configured,
unavailable,
ignored,
ignoredUntil: Number(ignoredUntil || 0),
shouldBlock: unavailable && !ignored,
status: availability.status,
versions: runtime?.versions || {},
};
};
export const releaseChannelUnavailableStatus = computed(() => {
const key = releaseChannelKey(releaseRuntimeState.channel);
const ignoredUntil = key ? Number(state.ignoredUntilByChannel[key] || 0) : 0;
return getReleaseChannelUnavailableStatus(releaseRuntimeState, state.now, ignoredUntil);
});
export const releaseChannelOptions = computed(() => getReleaseChannelOptions(releaseRuntimeState));
export const releaseChannelSelectorVisible = computed(() => hasSelectableReleaseChannels(releaseRuntimeState));
export const selectedReleaseChannelSlug = computed(() => state.selectedChannelSlug);
export const getSelectedReleaseChannelSlug = () => state.selectedChannelSlug || readSelectedChannelSlug();
export const releaseChannelRuntimeRequestParams = () => {
const slug = getSelectedReleaseChannelSlug();
return slug ? { release_channel: slug } : {};
};
const RELEASE_CHANNEL_API_FAILURE_STATUSES = new Set([404, 502, 503, 504]);
const releaseRuntimeApiBaseUrl = (runtime = {}) =>
String(runtime?.apiBaseUrl || runtime?.api_base_url || runtime?.urls?.api_base_url || "")
.trim()
.replace(/\/+$/, "");
const releaseRuntimeFrontendBaseUrl = (runtime = {}) =>
String(runtime?.frontendBaseUrl || runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || "")
.trim()
.replace(/\/+$/, "");
export const isReleaseChannelApiAvailabilityError = (error, runtime = releaseRuntimeState) => {
const status = Number(error?.response?.status || 0);
if (!RELEASE_CHANNEL_API_FAILURE_STATUSES.has(status)) {
return false;
}
const channelSlug = normalizeReleaseChannelSlug(runtime?.channel?.slug || getSelectedReleaseChannelSlug());
const isDefaultChannel =
runtime?.channel?.default_channel === true || runtime?.channel?.default_channel === 1 || channelSlug === "stable";
if (!channelSlug || isDefaultChannel) {
return false;
}
const requestUrl = String(error?.config?.url || error?.request?.responseURL || "");
if (!requestUrl.includes("/auth/session")) {
return false;
}
const apiBaseUrl = releaseRuntimeApiBaseUrl(runtime);
return !apiBaseUrl || requestUrl.startsWith(apiBaseUrl);
};
export const markReleaseChannelApiUnavailable = (
runtime = releaseRuntimeState,
missingKey = "api_base_url"
) => {
const channelSlug = normalizeReleaseChannelSlug(runtime?.channel?.slug || getSelectedReleaseChannelSlug());
const isDefaultChannel =
runtime?.channel?.default_channel === true || runtime?.channel?.default_channel === 1 || channelSlug === "stable";
if (!channelSlug || isDefaultChannel) {
return null;
}
const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {};
const missing = Array.from(new Set([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey]));
const nextRuntime = {
trace_id: runtime?.traceId || runtime?.trace_id || null,
channel: runtime?.channel || {
slug: channelSlug,
name: channelSlug,
default_channel: false,
},
available_channels: runtime?.availableChannels || runtime?.available_channels || [],
versions: runtime?.versions || {},
frontend_base_url: releaseRuntimeFrontendBaseUrl(runtime) || null,
api_base_url: releaseRuntimeApiBaseUrl(runtime) || null,
urls: {
frontend_base_url: releaseRuntimeFrontendBaseUrl(runtime) || null,
api_base_url: releaseRuntimeApiBaseUrl(runtime) || null,
},
availability: {
...availability,
configured: false,
explicit: true,
missing,
status: "unconfigured",
},
capture_policy: runtime?.capturePolicy || runtime?.capture_policy || {},
};
configureReleaseRuntime(nextRuntime);
return nextRuntime;
};
export const selectReleaseChannel = (channelOrSlug) => {
const slug = normalizeReleaseChannelSlug(
typeof channelOrSlug === "string" ? channelOrSlug : channelOrSlug?.slug || channelOrSlug?.channelSlug || ""
);
state.selectedChannelSlug = slug;
writeSelectedChannelSlug(slug);
return slug;
};
export const switchSelectedReleaseChannel = async (channelOrSlug, refreshRuntime) => {
if (typeof refreshRuntime !== "function") {
throw new Error("Release runtime refresh is not available.");
}
const previousSlug = getSelectedReleaseChannelSlug();
const targetSlug = selectReleaseChannel(channelOrSlug);
if (!targetSlug) {
return "";
}
try {
const runtime = (await refreshRuntime({ throwOnError: true })) || releaseRuntimeState;
const confirmedSlug = releaseChannelKey(runtime?.channel || releaseRuntimeState.channel);
if (confirmedSlug !== targetSlug) {
throw new Error(`Release runtime switched to ${confirmedSlug || "unknown"} instead of ${targetSlug}.`);
}
return targetSlug;
} catch (error) {
selectReleaseChannel(previousSlug);
try {
await refreshRuntime({ throwOnError: true });
} catch (restoreError) {
console.warn("Could not restore the previous release runtime after a failed switch.", restoreError);
}
throw error;
}
};
export const clearSelectedReleaseChannel = () => {
state.selectedChannelSlug = "";
writeSelectedChannelSlug("");
};
export const reconcileSelectedReleaseChannel = (runtime = releaseRuntimeState) => {
const hasExplicitOptions =
Array.isArray(runtime?.availableChannels) || Array.isArray(runtime?.available_channels);
const selected = getSelectedReleaseChannelSlug();
if (!hasExplicitOptions || !selected) {
return selected;
}
const hasSelectedOption = getReleaseChannelOptions(runtime).some((option) => option.channelSlug === selected);
if (!hasSelectedOption) {
clearSelectedReleaseChannel();
return "";
}
return selected;
};
export const releaseChannelSwitchNoticeKey = (channel, principalKey = state.switchNoticePrincipalKey) => {
const channelKey = releaseChannelKey(channel);
const principal = String(principalKey || "").trim();
return channelKey && principal ? `${principal}:${channelKey}` : "";
};
export const getReleaseChannelSwitchedStatus = (
runtime = {},
principalKey = state.switchNoticePrincipalKey,
seenMap = state.switchNoticeSeenByKey
) => {
const unavailableStatus = getReleaseChannelUnavailableStatus(runtime, state.now, 0);
const noticeKey = releaseChannelSwitchNoticeKey(unavailableStatus.channel, principalKey);
const seen = noticeKey ? Boolean(seenMap[noticeKey]) : false;
const shouldShow =
Boolean(noticeKey) &&
Boolean(unavailableStatus.channelSlug) &&
unavailableStatus.defaultChannel === false &&
unavailableStatus.explicitAvailability === true &&
unavailableStatus.configured === true &&
unavailableStatus.unavailable === false &&
unavailableStatus.channelSlug !== "stable" &&
seen === false;
return {
...unavailableStatus,
noticeKey,
principalKey: String(principalKey || "").trim(),
seen,
shouldShow,
};
};
export const releaseChannelSwitchedStatus = computed(() => {
return getReleaseChannelSwitchedStatus(
releaseRuntimeState,
state.switchNoticePrincipalKey,
state.switchNoticeSeenByKey
);
});
export const acknowledgeReleaseChannelSwitch = (
channel = releaseChannelSwitchedStatus.value.channel,
principalKey = state.switchNoticePrincipalKey
) => {
const noticeKey = releaseChannelSwitchNoticeKey(channel, principalKey);
if (!noticeKey) {
return "";
}
const nextMap = {
...state.switchNoticeSeenByKey,
[noticeKey]: new Date().toISOString(),
};
state.switchNoticeSeenByKey = nextMap;
writeSwitchNoticeMap(nextMap);
return noticeKey;
};
export const ignoreUnavailableReleaseChannel = (
channel = releaseChannelUnavailableStatus.value.channel,
durationMs = RELEASE_CHANNEL_IGNORE_MS
) => {
const key = releaseChannelKey(channel);
if (!key) {
return 0;
}
const ignoredUntil = Date.now() + durationMs;
const nextMap = {
...state.ignoredUntilByChannel,
[key]: ignoredUntil,
};
state.ignoredUntilByChannel = nextMap;
state.now = Date.now();
writeIgnoreMap(nextMap);
return ignoredUntil;
};
export const clearUnavailableReleaseChannelIgnore = (channel = releaseChannelUnavailableStatus.value.channel) => {
const key = releaseChannelKey(channel);
if (!key) {
return;
}
const nextMap = { ...state.ignoredUntilByChannel };
delete nextMap[key];
state.ignoredUntilByChannel = nextMap;
state.now = Date.now();
writeIgnoreMap(nextMap);
};
export const startReleaseChannelAvailabilityClock = () => {
if (typeof window === "undefined" || clockTimer !== null) {
return () => {};
}
clockTimer = window.setInterval(() => {
state.now = Date.now();
}, 1000);
return stopReleaseChannelAvailabilityClock;
};
export const stopReleaseChannelAvailabilityClock = () => {
if (typeof window === "undefined" || clockTimer === null) {
return;
}
window.clearInterval(clockTimer);
clockTimer = null;
};
export const buildReleaseFrontendRedirectUrl = () => null;
export const redirectToConfiguredReleaseFrontend = () => false;
export const __resetReleaseChannelAvailabilityForTests = () => {
state.now = Date.now();
state.ignoredUntilByChannel = {};
state.switchNoticeSeenByKey = {};
state.switchNoticePrincipalKey = "";
state.selectedChannelSlug = "";
stopReleaseChannelAvailabilityClock();
if (typeof window !== "undefined") {
window.localStorage.removeItem(RELEASE_CHANNEL_IGNORE_STORAGE_KEY);
window.localStorage.removeItem(RELEASE_CHANNEL_SWITCH_NOTICE_STORAGE_KEY);
window.localStorage.removeItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY);
}
};