Files
pleno-vue/src/components/global/RequestQueueProgress.vue
T

1500 lines
48 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import axios from "axios";
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import {
buildReleaseSessionSummary,
getReleaseRuntimeApiBaseUrl,
resolveReleaseApiUrl,
} from "@/services/releaseTimeline.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
clearErrorRequests,
clearMissingPermissions,
requestQueueState,
} from "@/services/requestQueue.js";
import {
I18N_CATALOG_VERSIONS,
activeI18nCatalogVersion,
setI18nCatalogVersion,
} from "@/i18n";
const METHOD_ICON_CLASS = Object.freeze({
GET: "fa-download",
POST: "fa-plus-circle",
PATCH: "fa-sliders-h",
PUT: "fa-pen",
DELETE: "fa-trash",
COMPONENT: "fa-puzzle-piece",
DEFAULT: "fa-exchange-alt",
});
const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
{
key: "ping",
label: "Ping",
iconClass: "fa-signal",
matcher: (url) => /\/ping(?:[/?#]|$)/i.test(url),
},
{
key: "bookings",
label: "Bookings",
iconClass: "fa-calendar-alt",
matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url),
},
]);
const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
const isShortcutActivated = ref(false);
const nowMs = ref(Date.now());
const pingLatencyMs = ref(null);
const pingIsUnavailable = ref(false);
const lastShiftKeyPressedAtMs = ref(0);
const shiftKeyPressCount = ref(0);
let tickerTimer = null;
let pingTimer = null;
const hasOutstandingRequests = computed(() => requestQueueState.pending + requestQueueState.active > 0);
const processedRequests = computed(() => requestQueueState.batchCompleted + requestQueueState.batchFailed);
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
const activeRequests = computed(() => requestQueueState.activeRequests || []);
const recentRequests = computed(() => requestQueueState.recentRequests || []);
const errorRequests = computed(() => requestQueueState.errorRequests || []);
const networkTotals = computed(() => requestQueueState.networkTotals || {
outgoingRequests: 0,
ingoingResponses: 0,
outgoingBytes: 0,
ingoingBytes: 0,
});
const shouldRenderComponent = computed(() => isShortcutActivated.value === true);
const appEnvironmentLabel = computed(() => {
const isDev = import.meta.env.VITE_IS_DEV;
return isDev ? "Development" : "Production";
});
const appCommitHash = computed(() => String(import.meta.env.VITE_COMMIT_HASH || "unknown"));
const appVersion = computed(() => String(import.meta.env.VITE_APP_VERSION || "0.0.0"));
const permissionGrantStatusByKey = ref({});
const requestInsightHistory = ref({});
const i18nCatalogVersionLabel = computed(() => activeI18nCatalogVersion.value.toUpperCase());
const nextI18nCatalogVersion = computed(() => {
const currentIndex = I18N_CATALOG_VERSIONS.indexOf(activeI18nCatalogVersion.value);
const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % I18N_CATALOG_VERSIONS.length : 0;
return I18N_CATALOG_VERSIONS[nextIndex];
});
const currentUrlHost = computed(() => {
if (typeof window === "undefined" || !window.location) {
return "unknown";
}
return window.location.host || "unknown";
});
const appBuildTime = computed(() => {
const raw = import.meta.env.VITE_BUILD_DATE;
if (!raw) {
return "unknown";
}
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) {
return String(raw);
}
return parsed.toLocaleString();
});
const userTypeLabel = computed(() => (SessionUser.isSubuser.value ? "Subuser" : "User"));
const hasSuperuserToken = computed(() => {
try {
return Boolean(localStorage.getItem("superuser_token"));
} catch (error) {
return false;
}
});
const impersonatedUserRoleId = computed(() => {
const roleId = Number.parseInt(SessionUser.user.group_id.value, 10);
return Number.isInteger(roleId) && roleId > 0 ? roleId : null;
});
const canGrantMissingPermissions = computed(() =>
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
);
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
const userDetailRows = computed(() => {
if (SessionUser.isSubuser.value) {
const selectedGrant = SessionUser.subuser.getSelectedGrant?.() || null;
return [
{ label: "Name", value: SessionUser.subuser.name.value || SessionUser.subuser.username.value || "N/A" },
{ label: "Subuser ID", value: SessionUser.subuser.id.value ?? "N/A" },
{ label: "Email", value: SessionUser.subuser.email.value || "N/A" },
{ label: "Grants", value: Array.isArray(SessionUser.subuser.grants.value) ? SessionUser.subuser.grants.value.length : 0 },
{ label: "Selected customer", value: selectedGrant?.billing_customer_number ?? SessionUser.subuser.selectedGrantCustomerNumber.value ?? "N/A" },
];
}
return [
{ label: "Name", value: SessionUser.user.display_name.value || SessionUser.getName?.() || "N/A" },
{ label: "Customer no.", value: SessionUser.user.customer_number.value ?? "N/A" },
{ label: "Group ID", value: SessionUser.user.group_id.value ?? "N/A" },
{ label: "Email", value: SessionUser.user.email.value || "N/A" },
{ label: "Phone", value: SessionUser.user.phone.number.value || "N/A" },
];
});
const progressPercent = computed(() => {
if (requestQueueState.batchTotal === 0) {
return 0;
}
return Math.round((processedRequests.value / requestQueueState.batchTotal) * 100);
});
const statusText = computed(() => {
if (hasOutstandingRequests.value) {
return `Request queue: ${requestQueueState.active} active, ${requestQueueState.pending} queued`;
}
return `Request queue complete: ${processedRequests.value}/${requestQueueState.batchTotal}`;
});
const pingText = computed(() => {
if (pingLatencyMs.value !== null) {
return `${pingLatencyMs.value} ms`;
}
if (pingIsUnavailable.value) {
return "Unavailable";
}
return "Checking...";
});
const formatDuration = (value) => {
const milliseconds = Math.max(0, Number(value) || 0);
if (milliseconds < 1000) {
return `${milliseconds} ms`;
}
if (milliseconds < 60000) {
return `${(milliseconds / 1000).toFixed(2)} s`;
}
return `${(milliseconds / 60000).toFixed(1)} min`;
};
const formatBytes = (value) => {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatTimeAgo = (timestamp) => {
const value = Number(timestamp || 0);
if (!value || Number.isNaN(value)) {
return "";
}
const elapsedMs = Math.max(0, nowMs.value - value);
if (elapsedMs < 1000) {
return "now";
}
if (elapsedMs < 60_000) {
return `${Math.floor(elapsedMs / 1000)}s ago`;
}
if (elapsedMs < 3_600_000) {
return `${Math.floor(elapsedMs / 60_000)}m ago`;
}
if (elapsedMs < 86_400_000) {
return `${Math.floor(elapsedMs / 3_600_000)}h ago`;
}
return `${Math.floor(elapsedMs / 86_400_000)}d ago`;
};
const getMethodIconClass = (method) => METHOD_ICON_CLASS[String(method || "").toUpperCase()] || METHOD_ICON_CLASS.DEFAULT;
const getActiveRequestElapsedMs = (request) => Math.max(0, nowMs.value - Number(request?.startedAt || nowMs.value));
const hasInsightEntryChanged = (currentEntry, nextEntry) =>
Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0)
|| Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0)
|| Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0)
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0);
const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => {
const matchedRequestFromRecent = recentRequests.value.find((request) =>
definition.matcher(String(request?.url || ""))
) || null;
const matchedRequest = matchedRequestFromRecent || requestInsightHistory.value[definition.key] || null;
const hasData = matchedRequest !== null;
return {
...definition,
latencyText: hasData ? `${Math.max(0, Number(matchedRequest.requestDurationMs) || 0)} ms` : " ",
timeAgoText: hasData
? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt)
: "",
};
}));
watch(recentRequests, (requests) => {
if (!Array.isArray(requests) || requests.length === 0) {
return;
}
const nextHistory = { ...requestInsightHistory.value };
let hasChanges = false;
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
const matchedRequest = requests.find((request) =>
definition.matcher(String(request?.url || ""))
);
if (!matchedRequest) {
return;
}
const nextEntry = {
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
completedAt: matchedRequest.completedAt || null,
startedAt: matchedRequest.startedAt || null,
queuedAt: matchedRequest.queuedAt || null,
};
if (!hasInsightEntryChanged(nextHistory[definition.key], nextEntry)) {
return;
}
nextHistory[definition.key] = nextEntry;
hasChanges = true;
});
if (hasChanges) {
requestInsightHistory.value = nextHistory;
}
}, { immediate: true });
const resolvePingUrl = () => {
const configuredEndpoint = REQUEST_QUEUE_CONFIG.ping.endpoint;
if (!configuredEndpoint) {
return getReleaseRuntimeApiBaseUrl();
}
if (/^https?:\/\//i.test(configuredEndpoint)) {
return configuredEndpoint;
}
return resolveReleaseApiUrl(configuredEndpoint);
};
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const activeApiUrlLabel = computed(() =>
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
);
const releaseSessionSummary = computed(() =>
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
);
const measurePingLatency = async () => {
if (typeof fetch !== "function") {
pingLatencyMs.value = null;
pingIsUnavailable.value = true;
return;
}
const startedAt = Date.now();
const timeoutMs = Math.max(0, Number(REQUEST_QUEUE_CONFIG.ping.timeoutMs) || 0);
const controller = typeof AbortController === "function" ? new AbortController() : null;
let timeoutId = null;
try {
if (controller && timeoutMs > 0) {
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
}
await fetch(resolvePingUrl(), {
method: "GET",
cache: "no-store",
mode: "cors",
signal: controller?.signal,
});
const latencyMs = Math.max(0, Date.now() - startedAt);
pingLatencyMs.value = latencyMs;
pingIsUnavailable.value = false;
requestInsightHistory.value = {
...requestInsightHistory.value,
ping: {
requestDurationMs: latencyMs,
completedAt: Date.now(),
startedAt,
queuedAt: startedAt,
},
};
} catch (error) {
pingLatencyMs.value = null;
pingIsUnavailable.value = true;
} finally {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
}
};
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
const handleWindowKeydown = (event) => {
if (event?.key === "Escape") {
if (isShortcutActivated.value) {
isShortcutActivated.value = false;
isExpanded.value = REQUEST_QUEUE_CONFIG.inspector.expandedByDefault;
}
return;
}
if (event?.key !== "Shift" || event?.repeat === true) {
return;
}
const pressedAt = Date.now();
if (
Number(lastShiftKeyPressedAtMs.value) > 0
&& (pressedAt - Number(lastShiftKeyPressedAtMs.value)) <= SHIFT_MULTI_PRESS_WINDOW_MS
) {
shiftKeyPressCount.value = Number(shiftKeyPressCount.value) + 1;
} else {
shiftKeyPressCount.value = 1;
}
if (shiftKeyPressCount.value >= 3) {
closeSystemSearchForSuperuser();
isShortcutActivated.value = true;
isExpanded.value = true;
shiftKeyPressCount.value = 0;
}
lastShiftKeyPressedAtMs.value = pressedAt;
};
const handleClearErrors = () => {
clearErrorRequests();
};
const handleClearMissingPermissions = () => {
clearMissingPermissions();
};
const handleToggleI18nCatalogVersion = () => {
setI18nCatalogVersion(nextI18nCatalogVersion.value);
};
const getPermissionGrantStatus = (permission) =>
permissionGrantStatusByKey.value[String(permission || "")] || "idle";
const setPermissionGrantStatus = (permission, status) => {
const key = String(permission || "");
permissionGrantStatusByKey.value = {
...permissionGrantStatusByKey.value,
[key]: status,
};
};
const grantMissingPermission = async (permission) => {
const normalizedPermission = String(permission || "").trim();
if (!normalizedPermission || !canGrantMissingPermissions.value) {
return;
}
const currentStatus = getPermissionGrantStatus(normalizedPermission);
if (currentStatus === "loading" || currentStatus === "success") {
return;
}
const superuserToken = localStorage.getItem("superuser_token");
const roleId = impersonatedUserRoleId.value;
if (!superuserToken || !roleId) {
return;
}
setPermissionGrantStatus(normalizedPermission, "loading");
try {
await axios.post(
resolveReleaseApiUrl("/roles/permissions"),
{
group_id: roleId,
permission_id: normalizedPermission,
},
{
headers: {
Authorization: `Bearer ${superuserToken}`,
},
}
);
setPermissionGrantStatus(normalizedPermission, "success");
if (
Array.isArray(SessionUser.permissions.value)
&& !SessionUser.permissions.value.includes(normalizedPermission)
) {
SessionUser.permissions.value = [...SessionUser.permissions.value, normalizedPermission];
}
} catch (error) {
console.error("Failed to grant missing permission", normalizedPermission, error);
setPermissionGrantStatus(normalizedPermission, "idle");
}
};
const closeSystemSearchForSuperuser = () => {
if (typeof SessionUser.canAccessSuperUser !== "function") {
return;
}
if (!SessionUser.canAccessSuperUser()) {
return;
}
window.dispatchEvent(new CustomEvent(SYSTEM_SEARCH_CLOSE_EVENT, {
detail: {
source: "request-queue-progress-shortcut",
},
}));
};
onMounted(() => {
window.addEventListener("keydown", handleWindowKeydown);
tickerTimer = setInterval(() => {
nowMs.value = Date.now();
}, REQUEST_QUEUE_CONFIG.inspector.timerRefreshMs);
void measurePingLatency();
pingTimer = setInterval(() => {
void measurePingLatency();
}, REQUEST_QUEUE_CONFIG.ping.intervalMs);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handleWindowKeydown);
if (tickerTimer !== null) {
clearInterval(tickerTimer);
tickerTimer = null;
}
if (pingTimer !== null) {
clearInterval(pingTimer);
pingTimer = null;
}
});
</script>
<template>
<transition name="queue-progress-fade">
<div v-if="shouldRenderComponent" class="request-queue-progress-shell" data-testid="request-queue-progress">
<div v-if="isExpanded" class="request-queue-progress__expanded-layout">
<aside class="request-queue-progress__side request-queue-progress__side--errors" data-testid="request-queue-errors-box">
<div class="request-queue-progress__side-header">
<div class="request-queue-progress__section-title">Errors ({{ errorRequests.length }})</div>
<button
class="request-queue-progress__clear-button"
data-testid="request-queue-clear-errors"
type="button"
:disabled="errorRequests.length === 0"
@click="handleClearErrors"
>
Clear
</button>
</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--errors">
<ul v-if="errorRequests.length > 0" class="request-queue-progress__list">
<li v-for="request in errorRequests" :key="request.id" class="request-queue-progress__error-item">
<div class="request-queue-progress__error-summary">
<span class="request-queue-progress__method">
<i class="fas" :class="getMethodIconClass(request.method)" aria-hidden="true"></i>
{{ request.method }}
</span>
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span>
<span class="request-queue-progress__error-meta">
{{ request.statusCode || "n/a" }} - {{ formatDuration(request.requestDurationMs) }}
</span>
</div>
<details class="request-queue-progress__error-details">
<summary>Request and response data</summary>
<div class="request-queue-progress__payloads">
<div class="request-queue-progress__payload-block">
<div class="request-queue-progress__payload-title">Request</div>
<pre>{{ request.requestText }}</pre>
</div>
<div class="request-queue-progress__payload-block">
<div class="request-queue-progress__payload-title">Response</div>
<pre>{{ request.responseText }}</pre>
</div>
</div>
</details>
</li>
</ul>
<div v-else class="request-queue-progress__empty">No stored errors</div>
</div>
</aside>
<div class="request-queue-progress__details" data-testid="request-queue-progress-details">
<div class="request-queue-progress__details-row">
<span class="request-queue-progress__details-label">Ping latency</span>
<strong class="request-queue-progress__ping-value">{{ pingText }}</strong>
</div>
<div class="request-queue-progress__section">
<div class="request-queue-progress__section-title">Active requests ({{ activeRequests.length }})</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--active">
<ul v-if="activeRequests.length > 0" class="request-queue-progress__list">
<li v-for="request in activeRequests" :key="request.id" class="request-queue-progress__list-item">
<span class="request-queue-progress__method">
<i class="fas" :class="getMethodIconClass(request.method)" aria-hidden="true"></i>
{{ request.method }}
</span>
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span>
<span class="request-queue-progress__time">{{ formatDuration(getActiveRequestElapsedMs(request)) }}</span>
</li>
</ul>
<div v-else class="request-queue-progress__empty">No active requests</div>
</div>
</div>
<div class="request-queue-progress__section">
<div class="request-queue-progress__section-title">Recent requests</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--recent">
<ul v-if="recentRequests.length > 0" class="request-queue-progress__list">
<li v-for="request in recentRequests" :key="request.id" class="request-queue-progress__list-item">
<span class="request-queue-progress__method">
<i class="fas" :class="getMethodIconClass(request.method)" aria-hidden="true"></i>
{{ request.method }}
</span>
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span>
<span class="request-queue-progress__time">
{{ formatDuration(request.requestDurationMs) }}
</span>
</li>
</ul>
<div v-else class="request-queue-progress__empty">No recent requests</div>
</div>
</div>
<div class="request-queue-progress__bottom-requests" data-testid="request-queue-bottom-request-insights">
<div
v-for="insight in requestInsights"
:key="insight.key"
class="request-queue-progress__bottom-request"
:data-testid="`request-queue-insight-${insight.key}`"
>
<span class="request-queue-progress__bottom-request-label">
<i class="fas" :class="insight.iconClass" aria-hidden="true"></i>
{{ insight.label }}
</span>
<span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</span>
<span class="request-queue-progress__bottom-request-latency">{{ insight.latencyText }}</span>
</div>
</div>
</div>
<aside class="request-queue-progress__side request-queue-progress__side--permissions" data-testid="request-queue-missing-permissions-box">
<div class="request-queue-progress__side-header">
<div class="request-queue-progress__section-title">Missing permissions</div>
<button
class="request-queue-progress__clear-button"
data-testid="request-queue-clear-permissions"
type="button"
:disabled="missingPermissions.length === 0"
@click="handleClearMissingPermissions"
>
Clear
</button>
</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--permissions">
<ul v-if="missingPermissions.length > 0" class="request-queue-progress__list">
<li
v-for="item in missingPermissions"
:key="`${item.permission}:${item.method}:${item.lastSeenAt}`"
class="request-queue-progress__list-item request-queue-progress__list-item--permission"
>
<span class="request-queue-progress__permission">{{ item.permission }}</span>
<span class="request-queue-progress__permission-meta">
<i class="fas" :class="getMethodIconClass(item.method)" aria-hidden="true"></i>
{{ item.method }}
</span>
<span class="request-queue-progress__permission-meta">
{{ item.statusCode || "n/a" }} x{{ item.count }}
</span>
<button
v-if="canGrantMissingPermissions"
class="request-queue-progress__permission-action"
:class="{
'request-queue-progress__permission-action--success': getPermissionGrantStatus(item.permission) === 'success',
}"
:data-testid="`request-queue-permission-action-${item.permission}-${item.method}`"
type="button"
:disabled="getPermissionGrantStatus(item.permission) === 'loading' || getPermissionGrantStatus(item.permission) === 'success'"
@click="grantMissingPermission(item.permission)"
>
<i
class="fas"
:class="{
'fa-plus': getPermissionGrantStatus(item.permission) === 'idle',
'fa-spinner fa-spin': getPermissionGrantStatus(item.permission) === 'loading',
'fa-check': getPermissionGrantStatus(item.permission) === 'success',
}"
aria-hidden="true"
></i>
</button>
</li>
</ul>
<div v-else class="request-queue-progress__empty">No missing permissions detected</div>
</div>
</aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<template v-if="canInspectReleaseRuntime">
<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"
>
<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>
</template>
<div class="request-queue-progress__section-title">Runtime details</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">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</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">
<div class="request-queue-progress__section-title">Session user</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">User type</span>
<span class="request-queue-progress__meta-value">{{ userTypeLabel }}</span>
</li>
<li
v-for="detail in userDetailRows"
:key="detail.label"
class="request-queue-progress__meta-item"
>
<span class="request-queue-progress__meta-label">{{ detail.label }}</span>
<span class="request-queue-progress__meta-value" :title="String(detail.value)">{{ detail.value }}</span>
</li>
</ul>
</aside>
</div>
<div class="request-queue-progress" role="status" aria-live="polite">
<div class="request-queue-progress__header">
<div class="request-queue-progress__text">{{ statusText }}</div>
<button
class="request-queue-progress__toggle"
data-testid="request-queue-progress-toggle"
type="button"
@click="toggleExpanded"
>
<i class="fas" :class="isExpanded ? 'fa-chevron-down' : 'fa-chevron-up'" aria-hidden="true"></i>
</button>
</div>
<div class="request-queue-progress__track">
<div class="request-queue-progress__bar" :style="{ width: `${progressPercent}%` }"></div>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.request-queue-progress-shell {
position: fixed;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
width: min(1360px, calc(100vw - 24px));
max-height: calc(100vh - 12px);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.request-queue-progress {
width: min(720px, calc(100vw - 24px));
border-radius: 10px;
background: rgba(17, 24, 39, 0.95);
color: #ffffff;
padding: 10px 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.28);
}
.request-queue-progress__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.request-queue-progress__text {
font-size: 13px;
line-height: 1.2;
}
.request-queue-progress__toggle {
border: none;
background: transparent;
color: #ffffff;
cursor: pointer;
padding: 2px 4px;
}
.request-queue-progress__track {
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
overflow: hidden;
}
.request-queue-progress__bar {
height: 100%;
background: linear-gradient(90deg, #00d1b2 0%, #48c78e 100%);
transition: width 0.2s ease;
}
.request-queue-progress__expanded-layout {
--queue-progress-collapsed-height: 62px;
width: 100%;
display: grid;
grid-template-columns: minmax(250px, 1fr) minmax(520px, 720px) minmax(250px, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"errors details permissions"
"runtime details user";
gap: 12px;
align-items: stretch;
min-height: min(220px, calc(100vh - var(--queue-progress-collapsed-height) - 28px));
height: min(62vh, calc(100vh - var(--queue-progress-collapsed-height) - 28px));
max-height: calc(100vh - var(--queue-progress-collapsed-height) - 28px);
overflow: hidden;
}
.request-queue-progress__details {
grid-area: details;
border-radius: 10px;
background: rgba(17, 24, 39, 0.98);
color: #ffffff;
padding: 10px 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.request-queue-progress__side {
border-radius: 10px;
background: rgba(17, 24, 39, 0.98);
color: #ffffff;
padding: 10px 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.request-queue-progress__side--errors {
grid-area: errors;
}
.request-queue-progress__side--permissions {
grid-area: permissions;
}
.request-queue-progress__side--runtime {
grid-area: runtime;
}
.request-queue-progress__side--user {
grid-area: user;
}
.request-queue-progress__side-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.request-queue-progress__side-header .request-queue-progress__section-title {
margin-bottom: 0;
}
.request-queue-progress__details-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
margin-bottom: 8px;
gap: 8px;
}
.request-queue-progress__details-label {
color: rgba(255, 255, 255, 0.7);
}
.request-queue-progress__ping-value {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 1.2;
font-weight: 700;
letter-spacing: 0.01em;
color: #0b1220;
background: linear-gradient(135deg, #7ef9e3 0%, #5ce3b9 100%);
box-shadow: 0 0 0 1px rgba(126, 249, 227, 0.35), 0 4px 10px rgba(0, 209, 178, 0.25);
}
.request-queue-progress__section {
margin-top: 8px;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 0;
}
.request-queue-progress__section-content {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
padding-right: 2px;
scrollbar-width: thin;
scrollbar-color: rgba(94, 234, 212, 0.65) rgba(255, 255, 255, 0.08);
}
.request-queue-progress__section-content--active {
min-height: 54px;
}
.request-queue-progress__section-content--recent {
min-height: 70px;
}
.request-queue-progress__section-content--permissions {
min-height: 54px;
}
.request-queue-progress__section-content--errors {
min-height: 70px;
}
.request-queue-progress__section-content--meta {
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;
margin-bottom: 4px;
}
.request-queue-progress__clear-button {
border: 1px solid rgba(255, 255, 255, 0.35);
background: transparent;
color: #ffffff;
font-size: 11px;
line-height: 1;
border-radius: 999px;
padding: 3px 8px;
cursor: pointer;
white-space: nowrap;
}
.request-queue-progress__clear-button:disabled {
opacity: 0.45;
cursor: default;
}
.request-queue-progress__list {
list-style: none;
margin: 0;
padding: 0;
}
.request-queue-progress__list-item {
display: grid;
grid-template-columns: 110px 1fr auto;
gap: 8px;
align-items: center;
font-size: 12px;
padding: 3px 0;
}
.request-queue-progress__error-item {
padding: 4px 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.request-queue-progress__error-summary {
display: grid;
grid-template-columns: 110px 1fr auto;
gap: 8px;
align-items: center;
font-size: 12px;
}
.request-queue-progress__error-meta {
white-space: nowrap;
color: rgba(255, 255, 255, 0.75);
}
.request-queue-progress__error-details {
margin-top: 4px;
}
.request-queue-progress__error-details summary {
font-size: 12px;
cursor: pointer;
color: rgba(255, 255, 255, 0.85);
}
.request-queue-progress__payloads {
margin-top: 6px;
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.request-queue-progress__payload-block {
background: rgba(2, 6, 23, 0.5);
border-radius: 6px;
padding: 6px;
}
.request-queue-progress__payload-title {
font-size: 11px;
font-weight: 600;
margin-bottom: 4px;
}
.request-queue-progress__payload-block pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 11px;
line-height: 1.3;
}
@media (max-width: 1200px) {
.request-queue-progress__expanded-layout {
grid-template-columns: 1fr;
grid-template-rows: auto;
grid-template-areas:
"errors"
"details"
"permissions"
"runtime"
"user";
height: auto;
max-height: calc(100vh - 118px);
overflow-y: auto;
}
.request-queue-progress__section {
flex: 0 0 auto;
}
}
.request-queue-progress__method {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.request-queue-progress__endpoint {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.92;
}
.request-queue-progress__time {
white-space: nowrap;
opacity: 0.88;
min-width: 72px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.request-queue-progress__empty {
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
.request-queue-progress__list-item--permission {
grid-template-columns: 1fr auto auto auto;
}
.request-queue-progress__permission {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-queue-progress__permission-meta {
white-space: nowrap;
font-size: 11px;
opacity: 0.82;
display: inline-flex;
align-items: center;
gap: 4px;
}
.request-queue-progress__permission-action {
width: 22px;
height: 22px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.35);
background: transparent;
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.request-queue-progress__permission-action:disabled {
cursor: default;
opacity: 0.85;
}
.request-queue-progress__permission-action--success {
border-color: rgba(94, 234, 212, 0.75);
color: #5eead4;
}
.request-queue-progress__meta-list {
list-style: none;
margin: 0;
padding: 0;
padding-right: 2px;
}
.request-queue-progress__section-content::-webkit-scrollbar,
.request-queue-progress__meta-list::-webkit-scrollbar,
.request-queue-progress__expanded-layout::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.request-queue-progress__section-content::-webkit-scrollbar-track,
.request-queue-progress__meta-list::-webkit-scrollbar-track,
.request-queue-progress__expanded-layout::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
}
.request-queue-progress__section-content::-webkit-scrollbar-thumb,
.request-queue-progress__meta-list::-webkit-scrollbar-thumb,
.request-queue-progress__expanded-layout::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(94, 234, 212, 0.8), rgba(72, 199, 142, 0.85));
border-radius: 999px;
border: 2px solid rgba(17, 24, 39, 0.65);
}
.request-queue-progress__section-content::-webkit-scrollbar-thumb:hover,
.request-queue-progress__meta-list::-webkit-scrollbar-thumb:hover,
.request-queue-progress__expanded-layout::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(126, 249, 227, 0.9), rgba(92, 214, 166, 0.95));
}
.request-queue-progress__meta-item {
display: grid;
grid-template-columns: 130px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
font-size: 12px;
line-height: 1.35;
padding: 2px 0;
}
.request-queue-progress__meta-label {
color: rgba(255, 255, 255, 0.75);
white-space: nowrap;
}
.request-queue-progress__meta-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
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;
background: rgba(126, 249, 227, 0.12);
color: #ffffff;
cursor: pointer;
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 4px 9px;
}
.request-queue-progress__catalog-switch:hover {
background: rgba(126, 249, 227, 0.22);
}
.request-queue-progress__bottom-requests {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
flex: 0 0 auto;
}
.request-queue-progress__bottom-request {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(2, 6, 23, 0.35);
}
.request-queue-progress__bottom-request-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.request-queue-progress__bottom-request-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.request-queue-progress__bottom-request-latency {
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
@media (max-width: 900px) {
.request-queue-progress__bottom-requests {
grid-template-columns: 1fr;
}
}
.queue-progress-fade-enter-active,
.queue-progress-fade-leave-active {
transition: opacity 0.2s ease;
}
.queue-progress-fade-enter-from,
.queue-progress-fade-leave-to {
opacity: 0;
}
</style>