From bbceaeb58addaf1ac3eca4fb1b117b8e24bf50fd Mon Sep 17 00:00:00 2001 From: Christian Johannesen Date: Sat, 24 Oct 2026 22:21:34 +0200 Subject: [PATCH] Improve payload request dashboard --- server/public/app.js | 132 +++++++++++++++++++++++++++++++++------ server/public/index.html | 52 ++++++++++++--- server/public/styles.css | 21 +++++++ server/src/server.js | 8 ++- 4 files changed, 184 insertions(+), 29 deletions(-) diff --git a/server/public/app.js b/server/public/app.js index d8122ac..9ec1a68 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -4,12 +4,25 @@ const elements = { apiCount: document.querySelector("#apiCount"), serverCount: document.querySelector("#serverCount"), activity: document.querySelector("#activity"), - payloads: document.querySelector("#payloads"), refresh: document.querySelector("#refresh"), + autoRefresh: document.querySelector("#autoRefresh"), + requestSearch: document.querySelector("#requestSearch"), + methodFilter: document.querySelector("#methodFilter"), + statusFilter: document.querySelector("#statusFilter"), + requestSort: document.querySelector("#requestSort"), + requestLimit: document.querySelector("#requestLimit"), + requestSummary: document.querySelector("#requestSummary"), + payloadModal: document.querySelector("#payloadModal"), + payloadModalTitle: document.querySelector("#payloadModalTitle"), + payloadMeta: document.querySelector("#payloadMeta"), + payloadDetails: document.querySelector("#payloadDetails"), + closePayloadModal: document.querySelector("#closePayloadModal"), columnToggles: [...document.querySelectorAll("[data-column-toggle]")], }; const columnPreferenceKey = "arduino-playground-visible-columns"; +const autoRefreshPreferenceKey = "arduino-playground-auto-refresh"; +let requests = []; function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ @@ -44,7 +57,71 @@ function saveColumnPreferences() { applyColumnPreferences(); } +function updateMethodFilter() { + const selected = elements.methodFilter.value; + const methods = [...new Set(requests.map((event) => event.detail.method).filter(Boolean))].sort(); + elements.methodFilter.innerHTML = '' + + methods.map((method) => ``).join(""); + elements.methodFilter.value = methods.includes(selected) ? selected : "all"; +} + +function filteredRequests() { + const query = elements.requestSearch.value.trim().toLocaleLowerCase(); + const method = elements.methodFilter.value; + const statusClass = elements.statusFilter.value; + const sort = elements.requestSort.value; + + return requests.filter((event) => { + const detail = event.detail; + if (method !== "all" && detail.method !== method) return false; + if (statusClass !== "all" && Math.floor((detail.status ?? 0) / 100) !== Number(statusClass)) return false; + return !query || JSON.stringify(event).toLocaleLowerCase().includes(query); + }).sort((left, right) => { + if (sort === "oldest") return new Date(left.timestamp) - new Date(right.timestamp); + if (sort === "status-asc") return (left.detail.status ?? 0) - (right.detail.status ?? 0); + if (sort === "status-desc") return (right.detail.status ?? 0) - (left.detail.status ?? 0); + return new Date(right.timestamp) - new Date(left.timestamp); + }); +} + +function openPayloadModal(event) { + const detail = event.detail; + if (!detail.payload) return; + elements.payloadModalTitle.textContent = `${detail.method} ${detail.path}`; + elements.payloadMeta.innerHTML = ` +
Time
${escapeHtml(new Date(event.timestamp).toLocaleString("en-GB"))}
+
Device
${escapeHtml(detail.deviceId ?? "—")}
+
Status
${escapeHtml(detail.status ?? "—")}
`; + elements.payloadDetails.textContent = JSON.stringify(detail.payload, null, 2); + elements.payloadModal.showModal(); +} + +function renderRequests() { + const matching = filteredRequests(); + const limit = Number(elements.requestLimit.value); + const visible = matching.slice(0, limit); + + elements.activity.innerHTML = visible.map((event) => { + const detail = event.detail; + const hasPayload = Boolean(detail.payload); + return ` + + ${new Date(event.timestamp).toLocaleString("en-GB")} + ${escapeHtml(detail.deviceId ?? "—")} + ${escapeHtml(detail.clientName ?? "—")} + ${escapeHtml(detail.path ?? "—")} + ${escapeHtml(detail.method ?? "—")} + ${escapeHtml(detail.status ?? "—")} + ${hasPayload ? '' : "—"} + `; + }).join("") || 'No matching requests.'; + + elements.requestSummary.textContent = `Showing ${visible.length} of ${matching.length} matching requests (${requests.length} total).`; + applyColumnPreferences(); +} + async function refresh() { + elements.refresh.disabled = true; try { const dashboardRequest = { headers: { "X-Monitor-Request": "dashboard" } }; const [healthResponse, payloadResponse, activityResponse] = await Promise.all([ @@ -58,34 +135,51 @@ async function refresh() { const [health, payloads, activity] = await Promise.all([ healthResponse.json(), payloadResponse.json(), activityResponse.json(), ]); - elements.health.textContent = "API online"; + elements.health.textContent = `API online · ${new Date(health.timestamp).toLocaleTimeString("en-GB")}`; elements.health.className = "status online"; elements.payloadCount.textContent = payloads.length; elements.apiCount.textContent = activity.filter((event) => event.source === "api").length; elements.serverCount.textContent = activity.filter((event) => event.source === "server").length; - elements.payloads.textContent = JSON.stringify(payloads, null, 2); - const requests = activity.filter((event) => event.source === "api"); - elements.activity.innerHTML = requests.slice(0, 30).map((event) => { - const detail = event.detail; - return ` - - ${new Date(event.timestamp).toLocaleTimeString("en-GB")} - ${escapeHtml(detail.deviceId ?? "—")} - ${escapeHtml(detail.clientName ?? "—")} - ${escapeHtml(detail.path ?? "—")} - ${escapeHtml(detail.method ?? "—")} - ${escapeHtml(detail.status ?? "—")} - `; - }).join("") || 'No external API requests yet.'; - applyColumnPreferences(); - } catch (error) { + requests = activity.filter((event) => event.source === "api"); + updateMethodFilter(); + renderRequests(); + } catch { elements.health.textContent = "Server offline"; elements.health.className = "status"; + } finally { + elements.refresh.disabled = false; } } elements.refresh.addEventListener("click", refresh); elements.columnToggles.forEach((toggle) => toggle.addEventListener("change", saveColumnPreferences)); +[elements.requestSearch, elements.methodFilter, elements.statusFilter, elements.requestSort, elements.requestLimit] + .forEach((control) => control.addEventListener("input", renderRequests)); +elements.activity.addEventListener("click", (event) => { + const row = event.target.closest("[data-request-id]"); + const request = requests.find((item) => item.id === row?.dataset.requestId); + if (request) openPayloadModal(request); +}); +elements.activity.addEventListener("keydown", (event) => { + if (!["Enter", " "].includes(event.key)) return; + const row = event.target.closest("[data-request-id]"); + const request = requests.find((item) => item.id === row?.dataset.requestId); + if (request?.detail.payload) { + event.preventDefault(); + openPayloadModal(request); + } +}); +elements.closePayloadModal.addEventListener("click", () => elements.payloadModal.close()); +elements.payloadModal.addEventListener("click", (event) => { + if (event.target === elements.payloadModal) elements.payloadModal.close(); +}); +elements.autoRefresh.addEventListener("change", () => { + localStorage.setItem(autoRefreshPreferenceKey, String(elements.autoRefresh.checked)); +}); + +elements.autoRefresh.checked = localStorage.getItem(autoRefreshPreferenceKey) !== "false"; applyColumnPreferences(); refresh(); -setInterval(refresh, 3000); +setInterval(() => { + if (elements.autoRefresh.checked && !document.hidden) refresh(); +}, 3000); diff --git a/server/public/index.html b/server/public/index.html index c7ce8ad..f39a202 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -26,7 +26,39 @@

Activity

Latest requests

- +
+ + +
+
+
+ + + + +
Visible columns @@ -46,21 +78,23 @@ Path Method Status + Payload -
- -
-
-

API

Payloads

- GET /api/payloads -
-
Loading…
+

+ + +
+

+    
diff --git a/server/public/styles.css b/server/public/styles.css index 9e6e908..a12b8ec 100644 --- a/server/public/styles.css +++ b/server/public/styles.css @@ -21,6 +21,15 @@ p { color: #91a99f; } .metrics strong { display: block; margin-top: 8px; font-size: 2.2rem; } .panel { padding: 24px; margin-top: 16px; } button { color: #e5f7ef; cursor: pointer; } +button:disabled { cursor: wait; opacity: .55; } +.refresh-actions, .request-controls { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; } +.toggle { display: flex; align-items: center; gap: 8px; color: #b8cec5; cursor: pointer; } +.toggle input, .request-controls input, .request-controls select { accent-color: #62e6ae; } +.request-controls { margin: 22px 0 4px; } +.request-controls label { display: grid; gap: 6px; color: #91a99f; font-size: .78rem; font-weight: 700; } +.request-controls input, .request-controls select { min-height: 42px; border: 1px solid #285244; border-radius: 10px; background: #07110e; color: #e5f7ef; padding: 8px 10px; } +.search-control { flex: 1 1 250px; } +.search-control input { width: 100%; } .column-picker { display: flex; flex-wrap: wrap; gap: 12px 20px; margin: 20px 0 14px; padding: 12px 16px; border: 1px solid #1d3a31; border-radius: 12px; } .column-picker legend { color: #91a99f; padding: 0 7px; font-size: .8rem; } .column-picker label { color: #b8cec5; font-size: .85rem; cursor: pointer; } @@ -30,15 +39,27 @@ button { color: #e5f7ef; cursor: pointer; } .activity-table th, .activity-table td { padding: 12px 10px; border-top: 1px solid #183129; text-align: left; white-space: nowrap; } .activity-table th { color: #91a99f; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; } .activity-table td { color: #d7e9e1; } +.payload-request { cursor: pointer; } +.payload-request:hover, .payload-request:focus { background: #102b23; outline: none; } +.payload-button { padding: 6px 11px; color: #62e6ae; } .time { color: #6f887e; font-variant-numeric: tabular-nums; } .method { color: #f5c96a; font-weight: 800; } .http-status { display: inline-block; min-width: 3.2rem; padding: 4px 8px; border-radius: 999px; text-align: center; background: #23342e; } .status-2xx { color: #62e6ae; background: #123a2d; } .status-4xx, .status-5xx { color: #ff9d91; background: #44201d; } .empty { color: #91a99f !important; text-align: center !important; padding: 28px !important; } +.request-summary { margin: 14px 0 0; font-size: .82rem; } [hidden] { display: none !important; } pre { overflow: auto; color: #bfead8; background: #07110e; padding: 18px; border-radius: 12px; } +.payload-modal { width: min(720px, calc(100% - 32px)); max-height: calc(100vh - 48px); border: 1px solid #285244; border-radius: 18px; background: #0b1c17; color: #e5f7ef; padding: 24px; box-shadow: 0 28px 90px #000b; } +.payload-modal::backdrop { background: rgba(0, 0, 0, .72); backdrop-filter: blur(3px); } +.modal-heading { display: flex; justify-content: space-between; align-items: start; gap: 16px; } +.payload-meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 22px 0 14px; } +.payload-meta div { padding: 12px; border: 1px solid #1d3a31; border-radius: 12px; } +.payload-meta dt { color: #91a99f; font-size: .72rem; text-transform: uppercase; } +.payload-meta dd { margin: 5px 0 0; overflow-wrap: anywhere; } @media (max-width: 650px) { header, .panel-heading { align-items: flex-start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } + .payload-meta { grid-template-columns: 1fr; } } diff --git a/server/src/server.js b/server/src/server.js index 80bf29a..4363597 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -95,7 +95,10 @@ app.use((req, res, next) => { !["/api/activity", "/api/health"].includes(req.path) ) { res.on("finish", () => { - record("api", "request", requestDetails(req, res.statusCode)); + record("api", "request", { + ...requestDetails(req, res.statusCode), + ...res.locals.activityDetail, + }); }); } next(); @@ -125,6 +128,7 @@ app.post("/api/payloads", requireBearer, async (req, res, next) => { }; payloads.push(payload); await persistPayloads(); + res.locals.activityDetail = { payload }; res.status(201).json(payload); } catch (error) { next(error); @@ -142,6 +146,7 @@ app.patch("/api/payloads/:id", requireBearer, async (req, res, next) => { updatedAt: new Date().toISOString(), }; await persistPayloads(); + res.locals.activityDetail = { payload: payloads[index] }; res.json(payloads[index]); } catch (error) { next(error); @@ -154,6 +159,7 @@ app.delete("/api/payloads/:id", requireBearer, async (req, res, next) => { if (index === -1) return res.status(404).json({ error: "Payload not found" }); const [removed] = payloads.splice(index, 1); await persistPayloads(); + res.locals.activityDetail = { payload: removed }; res.status(204).end(); } catch (error) { next(error);