1500 lines
48 KiB
Vue
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>
|
|
|