- 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.
575 lines
18 KiB
JavaScript
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);
|
|
}
|
|
};
|