diff --git a/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js b/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js index 287808de..41d8e66c 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/executive_utils.js @@ -1,17 +1,10 @@ +import {listenOnceMatchingWithTimeout} from "./pubsub/listeners"; import {get_ws_client, subscribeWS} from "./pubsub/ws"; const wsClient = get_ws_client(); const DEFAULT_EXECUTIVE_REFRESH_MS = 5000; let executiveRequestCounter = 0; -const listenOnce = (eventName, handler) => { - const wrapped = (msg) => { - wsClient.off(eventName, wrapped); - handler(msg); - }; - wsClient.on(eventName, wrapped); -}; - function normalizeEntityKinds(entityKinds) { return [...new Set((entityKinds || []).map((kind) => String(kind)))].sort(); } @@ -178,7 +171,7 @@ export function getNodeIdMap() { siteIdMap = map; resolve(map); }; - listenOnce("NetworkTreeLite", (msg) => { + listenOnceMatchingWithTimeout(wsClient, "NetworkTreeLite", 5000, () => true, (msg) => { const data = msg && msg.data ? msg.data : []; const map = new Map(); (data || []).forEach((entry) => { @@ -190,11 +183,10 @@ export function getNodeIdMap() { } }); resolveOnce(map); + }, () => { + resolveOnce(new Map()); }); wsClient.send({ NetworkTreeLite: {} }); - setTimeout(() => { - resolveOnce(new Map()); - }, 5000); }); return siteIdMapPromise; } diff --git a/src/rust/lqosd/src/node_manager/js_build/src/pubsub/listeners.js b/src/rust/lqosd/src/node_manager/js_build/src/pubsub/listeners.js new file mode 100644 index 00000000..cf0ccb8e --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/pubsub/listeners.js @@ -0,0 +1,23 @@ +export function listenOnceMatchingWithTimeout(wsClient, eventName, timeoutMs, matches, handler, onTimeout) { + let done = false; + const cleanup = wsClient.on(eventName, (msg) => { + if (done) return; + if (!matches(msg)) return; + done = true; + clearTimeout(timer); + cleanup(); + handler(msg); + }); + const timer = setTimeout(() => { + if (done) return; + done = true; + cleanup(); + onTimeout(); + }, timeoutMs); + return () => { + if (done) return; + done = true; + clearTimeout(timer); + cleanup(); + }; +} diff --git a/src/rust/lqosd/src/node_manager/js_build/src/pubsub/ws.test.mjs b/src/rust/lqosd/src/node_manager/js_build/src/pubsub/ws.test.mjs new file mode 100644 index 00000000..46622629 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/pubsub/ws.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +globalThis.window = { + LQOS_UI_VERSION: "test", + location: { search: "", protocol: "http:", host: "localhost" }, + localStorage: { getItem: () => null }, + sessionStorage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, +}; +globalThis.document = { cookie: "" }; + +const { listenOnceMatchingWithTimeout } = await import("./listeners.js"); + +function makeWsClient() { + const handlers = new Map(); + return { + on(eventName, handler) { + if (!handlers.has(eventName)) { + handlers.set(eventName, new Set()); + } + handlers.get(eventName).add(handler); + return () => this.off(eventName, handler); + }, + off(eventName, handler) { + handlers.get(eventName)?.delete(handler); + }, + emit(eventName, message) { + for (const handler of Array.from(handlers.get(eventName) || [])) { + handler(message); + } + }, + handlerCount(eventName) { + return handlers.get(eventName)?.size || 0; + }, + }; +} + +test("timeout listener ignores stale responses and resolves matching response", () => { + const wsClient = makeWsClient(); + const seen = []; + + listenOnceMatchingWithTimeout( + wsClient, + "UrgentClearResult", + 1000, + (message) => message.request_id === 2, + (message) => seen.push(message.ok), + () => seen.push("timeout"), + ); + + wsClient.emit("UrgentClearResult", { request_id: 1, ok: false }); + assert.deepEqual(seen, []); + assert.equal(wsClient.handlerCount("UrgentClearResult"), 1); + + wsClient.emit("UrgentClearResult", { request_id: 2, ok: true }); + assert.deepEqual(seen, [true]); + assert.equal(wsClient.handlerCount("UrgentClearResult"), 0); +}); + +test("timeout listener disposer removes pending handler", () => { + const wsClient = makeWsClient(); + const seen = []; + + const dispose = listenOnceMatchingWithTimeout( + wsClient, + "UrgentList", + 1000, + () => true, + () => seen.push("response"), + () => seen.push("timeout"), + ); + + assert.equal(wsClient.handlerCount("UrgentList"), 1); + dispose(); + wsClient.emit("UrgentList", { request_id: 1 }); + + assert.deepEqual(seen, []); + assert.equal(wsClient.handlerCount("UrgentList"), 0); +}); + +test("timeout listener fires timeout and removes pending handler", async () => { + const wsClient = makeWsClient(); + const seen = []; + + listenOnceMatchingWithTimeout( + wsClient, + "UrgentList", + 5, + () => false, + () => seen.push("response"), + () => seen.push("timeout"), + ); + + assert.equal(wsClient.handlerCount("UrgentList"), 1); + await new Promise((resolve) => setTimeout(resolve, 20)); + + assert.deepEqual(seen, ["timeout"]); + assert.equal(wsClient.handlerCount("UrgentList"), 0); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/template.js b/src/rust/lqosd/src/node_manager/js_build/src/template.js index 227ea555..cfed7976 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/template.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/template.js @@ -2,6 +2,7 @@ import {clearDiv} from "./helpers/builders"; import {initRedact} from "./helpers/redact"; import {initDayNightMode} from "./helpers/dark_mode"; import {initColorBlind} from "./helpers/colorblind"; +import {listenOnceMatchingWithTimeout as listenWsOnceMatchingWithTimeout} from "./pubsub/listeners"; import {get_ws_client} from "./pubsub/ws"; const wsClient = get_ws_client(); @@ -29,6 +30,7 @@ function escapeHtml(text) { const SCHEDULER_STATUS_POLL_MS = 2000; const SCHEDULER_STATUS_TIMEOUT_MS = 2500; +const URGENT_ISSUES_TIMEOUT_MS = 2500; const SCHEDULER_STATUS_STARTING_GRACE_MS = 30000; const SCHEDULER_MODAL_IDLE_POLL_MS = 3000; const SCHEDULER_MODAL_ACTIVE_POLL_MS = 1000; @@ -42,24 +44,6 @@ let schedulerModalInstance = null; let cachedSchedulerDetailsData = null; let cachedQueueModeConfig = null; -function listenOnceWithTimeout(eventName, timeoutMs, handler, onTimeout) { - let done = false; - const wrapped = (msg) => { - if (done) return; - done = true; - clearTimeout(timer); - wsClient.off(eventName, wrapped); - handler(msg); - }; - const timer = setTimeout(() => { - if (done) return; - done = true; - wsClient.off(eventName, wrapped); - onTimeout(); - }, timeoutMs); - wsClient.on(eventName, wrapped); -} - function clampSchedulerPercent(value) { const parsed = Number(value); if (!Number.isFinite(parsed)) return 0; @@ -526,7 +510,7 @@ function loadSchedulerStatus(force = false) { } schedulerStatusRequestInFlight = true; - listenOnceWithTimeout("SchedulerStatus", SCHEDULER_STATUS_TIMEOUT_MS, (msg) => { + listenWsOnceMatchingWithTimeout(wsClient, "SchedulerStatus", SCHEDULER_STATUS_TIMEOUT_MS, () => true, (msg) => { schedulerStatusRequestInFlight = false; if (!msg || !msg.data) { if (cachedSchedulerStatusData) { @@ -685,7 +669,7 @@ function refreshSchedulerModalDetails(showLoading = false) { renderSchedulerModalLoading(); } schedulerModalRequestInFlight = true; - listenOnceWithTimeout("SchedulerDetails", SCHEDULER_STATUS_TIMEOUT_MS, (msg) => { + listenWsOnceMatchingWithTimeout(wsClient, "SchedulerDetails", SCHEDULER_STATUS_TIMEOUT_MS, () => true, (msg) => { schedulerModalRequestInFlight = false; if (!isSchedulerModalOpen()) { stopSchedulerModalPolling(); @@ -1108,6 +1092,15 @@ function initUrgentIssues() { const containerId = 'urgentStatus'; const linkId = 'urgentStatusLink'; const badgeId = 'urgentBadge'; + let urgentClearState = null; + let urgentStatusPending = false; + let urgentStatusRefreshQueued = false; + let urgentListPending = false; + let urgentListActiveRequestId = null; + let urgentListCancel = null; + let urgentRequestId = 0; + let pendingUrgentActionError = null; + let pendingUrgentRetryMessage = null; function ensurePlaceholder() { return document.getElementById(containerId) !== null; @@ -1131,38 +1124,251 @@ function initUrgentIssues() { function poll() { if (!ensurePlaceholder()) return; - listenOnce("UrgentStatus", (msg) => { - const count = msg && msg.data ? msg.data.count : 0; - renderStatus(count || 0); + if (urgentStatusPending) { + urgentStatusRefreshQueued = true; + return; + } + urgentStatusPending = true; + const requestId = ++urgentRequestId; + listenWsOnceMatchingWithTimeout( + wsClient, + "UrgentStatus", + URGENT_ISSUES_TIMEOUT_MS, + (msg) => msg.request_id === requestId, + (msg) => { + urgentStatusPending = false; + const count = msg && msg.data ? msg.data.count : 0; + renderStatus(count || 0); + runQueuedUrgentStatusRefresh(); + }, + () => { + urgentStatusPending = false; + runQueuedUrgentStatusRefresh(); + } + ); + wsClient.send({ UrgentStatus: { request_id: requestId } }); + } + + function runQueuedUrgentStatusRefresh() { + if (!urgentStatusRefreshQueued) return false; + urgentStatusRefreshQueued = false; + poll(); + return true; + } + + function clearActionInFlight() { + return urgentClearState !== null; + } + + function clearActionBlocked() { + return clearActionInFlight() || urgentListPending; + } + + function setClearControlsDisabled(disabled) { + document.querySelectorAll('.urgent-clear').forEach((button) => { + button.disabled = disabled; }); - wsClient.send({ UrgentStatus: {} }); + const clearAllButton = document.getElementById('urgentClearAll'); + if (clearAllButton) { + clearAllButton.disabled = disabled; + } + } + + function showUrgentBusyState(message) { + const holder = document.getElementById('urgentListContainer'); + if (!holder || !urgentModalIsOpen()) return; + holder.setAttribute('aria-busy', 'true'); + holder.querySelector('.urgent-busy-status')?.remove(); + holder.insertAdjacentHTML('afterbegin', ` +
${it.context}` : ''}
`;
- tr.appendChild(td);
- tbody.appendChild(tr);
- });
- table.appendChild(tbody);
- holder.innerHTML = '';
- const tableWrap = document.createElement('div');
- tableWrap.className = 'table-responsive lqos-table-wrap';
- tableWrap.appendChild(table);
- holder.appendChild(tableWrap);
- $(holder).off('click').on('click', '.urgent-clear', function (e) {
- e.preventDefault();
- const id = $(this).data('id');
- listenOnce("UrgentClearResult", () => {
- showModal();
- poll();
+ tr.appendChild(td);
+ tbody.appendChild(tr);
});
- wsClient.send({ UrgentClear: { id } });
- });
- });
- wsClient.send({ UrgentList: {} });
+ table.appendChild(tbody);
+ holder.innerHTML = '';
+ const tableWrap = document.createElement('div');
+ tableWrap.className = 'table-responsive lqos-table-wrap';
+ tableWrap.appendChild(table);
+ holder.appendChild(tableWrap);
+ attachPendingUrgentActionError(holder);
+ $(holder).off('click').on('click', '.urgent-clear', function (e) {
+ e.preventDefault();
+ const id = $(this).data('id');
+ startUrgentClear({
+ responseEvent: 'UrgentClearResult',
+ requestPayload: (requestId) => ({ UrgentClear: { id, request_id: requestId } }),
+ busyMessage: 'Acknowledging urgent issue...',
+ failureMessage: 'Unable to acknowledge urgent issue.',
+ timeoutMessage: 'Timed out while acknowledging urgent issue.',
+ });
+ });
+ setClearControlsDisabled(clearActionInFlight());
+ focusUrgentModalFallback();
+ },
+ () => {
+ if (urgentListActiveRequestId !== requestId) return;
+ urgentListPending = false;
+ urgentListActiveRequestId = null;
+ urgentListCancel = null;
+ if (!urgentModalIsOpen()) {
+ setClearControlsDisabled(clearActionInFlight());
+ return;
+ }
+ showUrgentRetryState('Unable to refresh urgent issues.');
+ setClearControlsDisabled(true);
+ }
+ );
+ wsClient.send({ UrgentList: { request_id: requestId } });
}
if (!document.getElementById(containerId)) {
@@ -1206,11 +1430,31 @@ function initUrgentIssues() {
}
$(document).off('click', '#urgentClearAll').on('click', '#urgentClearAll', () => {
- listenOnce("UrgentClearAllResult", () => {
- showModal();
- poll();
+ startUrgentClear({
+ responseEvent: 'UrgentClearAllResult',
+ requestPayload: (requestId) => ({ UrgentClearAll: { request_id: requestId } }),
+ busyMessage: 'Acknowledging urgent issues...',
+ failureMessage: 'Unable to acknowledge urgent issues.',
+ timeoutMessage: 'Timed out while acknowledging urgent issues.',
});
- wsClient.send({ UrgentClearAll: {} });
+ });
+
+ $(document).off('hidden.bs.modal', '#urgentModal').on('hidden.bs.modal', '#urgentModal', () => {
+ pendingUrgentRetryMessage = null;
+ if (urgentClearState?.cancel) {
+ urgentClearState.cancel();
+ urgentClearState = null;
+ }
+ if (urgentListPending) {
+ if (urgentListCancel) {
+ urgentListCancel();
+ urgentListCancel = null;
+ }
+ urgentListPending = false;
+ urgentListActiveRequestId = null;
+ }
+ clearUrgentBusyState();
+ setClearControlsDisabled(clearActionInFlight());
});
poll();
diff --git a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh
index 5d093180..20456c61 100755
--- a/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh
+++ b/src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh
@@ -9,7 +9,9 @@ if ! command -v node >/dev/null 2>&1; then
fi
echo "[test-node-manager] Running frontend contract tests..."
-node --test "${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs"
+node --test \
+ "${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs" \
+ "${SCRIPT_DIR}/src/pubsub/ws.test.mjs"
echo "[test-node-manager] Building bundles..."
"${SCRIPT_DIR}/esbuild.sh"
diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs
index 6b5bb6eb..02766378 100644
--- a/src/rust/lqosd/src/node_manager/ws.rs
+++ b/src/rust/lqosd/src/node_manager/ws.rs
@@ -676,32 +676,37 @@ async fn receive_channel_message(
return true;
}
}
- WsRequest::UrgentStatus => {
+ WsRequest::UrgentStatus { request_id } => {
let response = WsResponse::UrgentStatus {
data: urgent::urgent_status_data(),
+ request_id,
};
if send_ws_response(&tx, response).await {
return true;
}
}
- WsRequest::UrgentList => {
+ WsRequest::UrgentList { request_id } => {
let response = WsResponse::UrgentList {
data: urgent::urgent_list_data(),
+ request_id,
};
if send_ws_response(&tx, response).await {
return true;
}
}
- WsRequest::UrgentClear { id } => {
+ WsRequest::UrgentClear { id, request_id } => {
let ok = urgent::urgent_clear_id(id);
- let response = WsResponse::UrgentClearResult { ok };
+ let response = WsResponse::UrgentClearResult { ok, request_id };
if send_ws_response(&tx, response).await {
return true;
}
}
- WsRequest::UrgentClearAll => {
+ WsRequest::UrgentClearAll { request_id } => {
urgent::urgent_clear_all_data();
- let response = WsResponse::UrgentClearAllResult { ok: true };
+ let response = WsResponse::UrgentClearAllResult {
+ ok: true,
+ request_id,
+ };
if send_ws_response(&tx, response).await {
return true;
}
@@ -2502,7 +2507,9 @@ fn payload_hint(payload: &[u8]) -> &'static str {
#[cfg(test)]
mod tests {
use super::decode_ws_request;
+ use crate::node_manager::local_api::urgent::{UrgentList, UrgentStatus};
use crate::node_manager::ws::messages::WsRequest;
+ use crate::node_manager::ws::messages::{WsResponse, encode_ws_message};
use serde_cbor::Value as CborValue;
use std::collections::BTreeMap;
@@ -2567,6 +2574,163 @@ mod tests {
.expect("valid update payload")
}
+ fn encode_response_value(response: &WsResponse) -> CborValue {
+ let payload = encode_ws_message(response).expect("response should encode");
+ serde_cbor::from_slice(payload.as_ref()).expect("response should decode as CBOR value")
+ }
+
+ fn response_request_id(response: &WsResponse, event_name: &str) -> Option