Enhance request tracking and error management in RequestQueue:

- Add missing permissions tracking with configurable limits.
- Introduce `clearMissingPermissions` and `pushMissingPermissions` functions.
- Update `RequestQueueProgress` layout to display missing permissions.
- Improve error handling with permission extraction from response payloads.
- Update tests for new functionality and expanded edge cases.
This commit is contained in:
Jeppe Bundgaard
2026-03-19 15:18:31 +01:00
parent 2011887a4b
commit 076bddab90
4 changed files with 366 additions and 34 deletions
+169 -31
View File
@@ -1,7 +1,11 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { API_URL, REQUEST_QUEUE_CONFIG } from "@/config.js";
import { requestQueueState } from "@/services/requestQueue.js";
import {
clearErrorRequests,
clearMissingPermissions,
requestQueueState,
} from "@/services/requestQueue.js";
const METHOD_ICON_CLASS = Object.freeze({
GET: "fa-download",
@@ -24,9 +28,13 @@ let pingTimer = null;
const hasOutstandingRequests = computed(() => requestQueueState.pending + requestQueueState.active > 0);
const processedRequests = computed(() => requestQueueState.batchCompleted + requestQueueState.batchFailed);
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
const hasStoredErrors = computed(() => (requestQueueState.errorRequests || []).length > 0);
const hasStoredPermissions = computed(() => missingPermissions.value.length > 0);
const hasStoredInspectorItems = computed(() => hasStoredErrors.value || hasStoredPermissions.value);
const shouldRenderForBatch = computed(() =>
hasStoredErrors.value || requestQueueState.batchTotal >= REQUEST_QUEUE_CONFIG.progress.minBatchSizeToShow
hasStoredInspectorItems.value
|| requestQueueState.batchTotal >= REQUEST_QUEUE_CONFIG.progress.minBatchSizeToShow
);
const activeRequests = computed(() => requestQueueState.activeRequests || []);
const recentRequests = computed(() => requestQueueState.recentRequests || []);
@@ -131,14 +139,22 @@ const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
watch([hasOutstandingRequests, shouldRenderForBatch, hasStoredErrors], ([hasOutstanding, shouldRender, hasErrors]) => {
const handleClearErrors = () => {
clearErrorRequests();
};
const handleClearMissingPermissions = () => {
clearMissingPermissions();
};
watch([hasOutstandingRequests, shouldRenderForBatch, hasStoredInspectorItems], ([hasOutstanding, shouldRender, hasInspectorItems]) => {
if (!shouldRender) {
clearHideTimer();
isVisible.value = false;
return;
}
if (hasOutstanding || hasErrors) {
if (hasOutstanding || hasInspectorItems) {
clearHideTimer();
isVisible.value = true;
return;
@@ -187,7 +203,18 @@ onBeforeUnmount(() => {
<div v-if="isVisible" 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" data-testid="request-queue-errors-box">
<div class="request-queue-progress__section-title">Errors ({{ errorRequests.length }})</div>
<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>
<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">
@@ -197,7 +224,7 @@ onBeforeUnmount(() => {
</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) }}
{{ request.statusCode || "n/a" }} - {{ formatDuration(request.requestDurationMs) }}
</span>
</div>
@@ -227,39 +254,73 @@ onBeforeUnmount(() => {
<div class="request-queue-progress__section">
<div class="request-queue-progress__section-title">Active requests ({{ activeRequests.length }})</div>
<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 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>
<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 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>
<aside class="request-queue-progress__side" data-testid="request-queue-missing-permissions-box">
<div class="request-queue-progress__section-title">Missing permissions</div>
<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.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>
</li>
</ul>
<div v-else class="request-queue-progress__empty">No missing permissions detected</div>
</div>
</aside>
</div>
@@ -347,6 +408,7 @@ onBeforeUnmount(() => {
grid-template-columns: minmax(250px, 1fr) minmax(520px, 720px) minmax(250px, 1fr);
gap: 12px;
align-items: stretch;
min-height: 200px;
}
.request-queue-progress__details {
@@ -355,6 +417,8 @@ onBeforeUnmount(() => {
color: #ffffff;
padding: 10px 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.28);
display: flex;
flex-direction: column;
}
.request-queue-progress__side {
@@ -365,6 +429,18 @@ onBeforeUnmount(() => {
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.28);
}
.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;
@@ -381,12 +457,51 @@ onBeforeUnmount(() => {
margin-top: 8px;
}
.request-queue-progress__section-content {
min-height: 48px;
max-height: 140px;
overflow-y: auto;
padding-right: 2px;
}
.request-queue-progress__section-content--active {
min-height: 54px;
max-height: 112px;
}
.request-queue-progress__section-content--recent {
min-height: 70px;
max-height: 150px;
}
.request-queue-progress__section-content--permissions {
min-height: 54px;
max-height: 220px;
}
.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;
@@ -480,6 +595,9 @@ onBeforeUnmount(() => {
.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 {
@@ -487,6 +605,25 @@ onBeforeUnmount(() => {
color: rgba(255, 255, 255, 0.75);
}
.request-queue-progress__list-item--permission {
grid-template-columns: 1fr 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;
}
.queue-progress-fade-enter-active,
.queue-progress-fade-leave-active {
transition: opacity 0.2s ease;
@@ -497,3 +634,4 @@ onBeforeUnmount(() => {
opacity: 0;
}
</style>
+1
View File
@@ -50,6 +50,7 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({
timerRefreshMs: 250,
recentRequestsLimit: 8,
errorHistoryLimit: 10,
missingPermissionsLimit: 20,
payloadMaxChars: 4000,
}),
// Queue progress widget behavior
+122
View File
@@ -12,6 +12,7 @@ const queueConfig = {
retryDelayJitterMs: REQUEST_QUEUE_CONFIG.retryDelay.jitterMs,
recentRequestsLimit: REQUEST_QUEUE_CONFIG.inspector.recentRequestsLimit,
errorHistoryLimit: REQUEST_QUEUE_CONFIG.inspector.errorHistoryLimit,
missingPermissionsLimit: REQUEST_QUEUE_CONFIG.inspector.missingPermissionsLimit,
payloadMaxChars: REQUEST_QUEUE_CONFIG.inspector.payloadMaxChars,
};
@@ -25,6 +26,7 @@ const requestQueueStateMutable = reactive({
activeRequests: [],
recentRequests: [],
errorRequests: [],
missingPermissions: [],
});
const requestQueue = [];
@@ -107,6 +109,65 @@ const toSafeText = (value) => {
return `${text.slice(0, maxChars)}\n... [truncated]`;
};
const parseJsonIfString = (value) => {
if (typeof value !== "string") {
return value;
}
const trimmedValue = value.trim();
if (!trimmedValue.startsWith("{") && !trimmedValue.startsWith("[")) {
return value;
}
try {
return JSON.parse(trimmedValue);
} catch (error) {
return value;
}
};
const normalizeResponseData = (value) => {
const parsed = parseJsonIfString(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return parsed;
}
const parsedData = parseJsonIfString(parsed.data);
if (parsedData === parsed.data) {
return parsed;
}
return {
...parsed,
data: parsedData,
};
};
const extractMissingPermissions = (error) => {
const responseData = normalizeResponseData(error?.response?.data);
const messageCandidates = [
responseData?.data?.message,
responseData?.message,
error?.message,
];
const hasMissingPermissionMessage = messageCandidates.some((message) =>
typeof message === "string" && message.toLowerCase().includes("missing permission")
);
if (!hasMissingPermissionMessage) {
return [];
}
const permissions = responseData?.data?.permissions ?? responseData?.permissions ?? [];
if (!Array.isArray(permissions)) {
return [];
}
return permissions
.map((permission) => String(permission || "").trim())
.filter((permission) => permission.length > 0);
};
const getMethodConcurrencyLimit = (method) => {
const normalizedMethod = normalizeMethod(method);
const configuredLimit = queueConfig.concurrencyByMethod[normalizedMethod]
@@ -252,6 +313,42 @@ const pushErrorRequest = (entry) => {
requestQueueStateMutable.errorRequests = next.slice(0, limit);
};
const pushMissingPermissions = (permissionEntries) => {
if (!Array.isArray(permissionEntries) || permissionEntries.length === 0) {
return;
}
const now = Date.now();
const existing = [...requestQueueStateMutable.missingPermissions];
permissionEntries.forEach(({ permission, method, url, statusCode }) => {
const index = existing.findIndex((item) => item.permission === permission);
if (index >= 0) {
existing[index] = {
...existing[index],
method,
url,
statusCode,
count: Number(existing[index].count || 0) + 1,
lastSeenAt: now,
};
return;
}
existing.push({
permission,
method,
url,
statusCode,
count: 1,
lastSeenAt: now,
});
});
existing.sort((a, b) => Number(b.lastSeenAt || 0) - Number(a.lastSeenAt || 0));
const limit = Math.max(1, Number(queueConfig.missingPermissionsLimit) || 20);
requestQueueStateMutable.missingPermissions = existing.slice(0, limit);
};
const runJob = (job) => {
const method = normalizeMethod(job.method);
const startedAt = Date.now();
@@ -324,6 +421,16 @@ const runJob = (job) => {
requestText: toSafeText(requestSnapshot),
responseText: toSafeText(responseSnapshot),
});
const missingPermissions = extractMissingPermissions(error);
pushMissingPermissions(
missingPermissions.map((permission) => ({
permission,
method,
url: job.url,
statusCode,
}))
);
job.reject(error);
})
.finally(() => {
@@ -389,6 +496,14 @@ export const enqueueRequest = (requestFactory, options = {}) => {
export const requestQueueState = readonly(requestQueueStateMutable);
export const clearErrorRequests = () => {
requestQueueStateMutable.errorRequests = [];
};
export const clearMissingPermissions = () => {
requestQueueStateMutable.missingPermissions = [];
};
export const __resetRequestQueueForTests = () => {
requestQueue.length = 0;
activeWorkers = 0;
@@ -406,6 +521,7 @@ export const __resetRequestQueueForTests = () => {
requestQueueStateMutable.activeRequests = [];
requestQueueStateMutable.recentRequests = [];
requestQueueStateMutable.errorRequests = [];
requestQueueStateMutable.missingPermissions = [];
queueConfig.concurrencyByMethod = cloneObject(REQUEST_QUEUE_CONFIG.concurrency);
queueConfig.spacingMs = 0;
@@ -415,6 +531,7 @@ export const __resetRequestQueueForTests = () => {
queueConfig.retryDelayJitterMs = 0;
queueConfig.recentRequestsLimit = REQUEST_QUEUE_CONFIG.inspector.recentRequestsLimit;
queueConfig.errorHistoryLimit = REQUEST_QUEUE_CONFIG.inspector.errorHistoryLimit;
queueConfig.missingPermissionsLimit = REQUEST_QUEUE_CONFIG.inspector.missingPermissionsLimit;
queueConfig.payloadMaxChars = REQUEST_QUEUE_CONFIG.inspector.payloadMaxChars;
};
@@ -429,6 +546,7 @@ export const __configureRequestQueueForTests = ({
retryDelayJitterMs,
recentRequestsLimit,
errorHistoryLimit,
missingPermissionsLimit,
payloadMaxChars,
} = {}) => {
if (concurrencyByMethod && typeof concurrencyByMethod === "object") {
@@ -479,6 +597,10 @@ export const __configureRequestQueueForTests = ({
queueConfig.errorHistoryLimit = Math.max(1, Math.floor(errorHistoryLimit));
}
if (typeof missingPermissionsLimit === "number" && missingPermissionsLimit > 0) {
queueConfig.missingPermissionsLimit = Math.max(1, Math.floor(missingPermissionsLimit));
}
if (typeof payloadMaxChars === "number" && payloadMaxChars >= 200) {
queueConfig.payloadMaxChars = Math.floor(payloadMaxChars);
}
+74 -3
View File
@@ -123,7 +123,12 @@ describe("RequestQueueProgress", () => {
});
it("stores errors with request and response payloads and respects configured error limit", async () => {
__configureRequestQueueForTests({ errorHistoryLimit: 1, payloadMaxChars: 8000 });
__configureRequestQueueForTests({
errorHistoryLimit: 1,
missingPermissionsLimit: 1,
retryByStatusCode: {},
payloadMaxChars: 8000
});
const wrapper = mount(RequestQueueProgress);
const requestOne = enqueueRequest(
@@ -144,7 +149,18 @@ describe("RequestQueueProgress", () => {
async () => {
throw {
message: "Second failure",
response: { status: 429, data: { reason: "second" } }
response: {
status: 403,
data: {
success: false,
data: {
message: "Missing permission(s)",
permissions: ["department_notification_sms_get"]
},
meta: [],
includes: []
}
}
};
},
{
@@ -160,7 +176,9 @@ describe("RequestQueueProgress", () => {
expect(requestQueueState.errorRequests.length).toBe(1);
expect(requestQueueState.errorRequests[0].url).toBe("/second-error");
expect(requestQueueState.errorRequests[0].requestText).toContain("requestBody");
expect(requestQueueState.errorRequests[0].responseText).toContain("second");
expect(requestQueueState.errorRequests[0].responseText).toContain("Missing permission(s)");
expect(requestQueueState.missingPermissions.length).toBe(1);
expect(requestQueueState.missingPermissions[0].permission).toBe("department_notification_sms_get");
await wrapper.get("[data-testid='request-queue-progress-toggle']").trigger("click");
await flushManyMicrotasks();
@@ -171,6 +189,22 @@ describe("RequestQueueProgress", () => {
expect(errorsBox.text()).toContain("Request and response data");
expect(errorsBox.text()).toContain("Request");
expect(errorsBox.text()).toContain("Response");
const missingPermissionsBox = wrapper.get("[data-testid='request-queue-missing-permissions-box']");
expect(missingPermissionsBox.text()).toContain("department_notification_sms_get");
await wrapper.get("[data-testid='request-queue-clear-errors']").trigger("click");
await flushManyMicrotasks();
expect(requestQueueState.errorRequests.length).toBe(0);
expect(wrapper.get("[data-testid='request-queue-errors-box']").text()).toContain("No stored errors");
expect(requestQueueState.missingPermissions.length).toBe(1);
await wrapper.get("[data-testid='request-queue-clear-permissions']").trigger("click");
await flushManyMicrotasks();
expect(requestQueueState.missingPermissions.length).toBe(0);
expect(wrapper.get("[data-testid='request-queue-missing-permissions-box']").text()).toContain("No missing permissions detected");
});
it("stays visible after errors occur", async () => {
@@ -207,4 +241,41 @@ describe("RequestQueueProgress", () => {
expect(errorsBox.text()).toContain("Errors");
expect(errorsBox.text()).toContain("/error-visible");
});
it("stores missing permissions when response payload is stringified JSON", async () => {
__configureRequestQueueForTests({
missingPermissionsLimit: 5,
retryByStatusCode: {},
});
const request = enqueueRequest(
async () => {
throw {
message: "Request failed with status code 403",
response: {
status: 403,
data: JSON.stringify({
success: false,
data: {
message: "Missing permission(s)",
permissions: ["department_notification_sms_get"],
},
meta: [],
includes: [],
}),
},
};
},
{
method: "GET",
url: "/notification/sms",
}
).catch((error) => error);
await request;
await flushManyMicrotasks();
expect(requestQueueState.missingPermissions.length).toBe(1);
expect(requestQueueState.missingPermissions[0].permission).toBe("department_notification_sms_get");
});
});