Improve payload request dashboard

This commit is contained in:
Christian Johannesen 2026-10-24 22:21:34 +02:00
parent 3960b4db6e
commit bbceaeb58a
4 changed files with 184 additions and 29 deletions

View file

@ -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 = '<option value="all">All</option>'
+ methods.map((method) => `<option value="${escapeHtml(method)}">${escapeHtml(method)}</option>`).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 = `
<div><dt>Time</dt><dd>${escapeHtml(new Date(event.timestamp).toLocaleString("en-GB"))}</dd></div>
<div><dt>Device</dt><dd>${escapeHtml(detail.deviceId ?? "—")}</dd></div>
<div><dt>Status</dt><dd>${escapeHtml(detail.status ?? "—")}</dd></div>`;
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 `
<tr data-request-id="${escapeHtml(event.id)}" class="${hasPayload ? "payload-request" : ""}" ${hasPayload ? 'tabindex="0"' : ""}>
<td class="time">${new Date(event.timestamp).toLocaleString("en-GB")}</td>
<td data-column="deviceId">${escapeHtml(detail.deviceId ?? "—")}</td>
<td data-column="clientName">${escapeHtml(detail.clientName ?? "—")}</td>
<td data-column="path"><code>${escapeHtml(detail.path ?? "—")}</code></td>
<td data-column="method"><span class="method">${escapeHtml(detail.method ?? "—")}</span></td>
<td data-column="status"><span class="http-status status-${Math.floor((detail.status ?? 0) / 100)}xx">${escapeHtml(detail.status ?? "—")}</span></td>
<td>${hasPayload ? '<button type="button" class="payload-button">View</button>' : "—"}</td>
</tr>`;
}).join("") || '<tr><td class="empty" colspan="7">No matching requests.</td></tr>';
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 `
<tr>
<td class="time">${new Date(event.timestamp).toLocaleTimeString("en-GB")}</td>
<td data-column="deviceId">${escapeHtml(detail.deviceId ?? "—")}</td>
<td data-column="clientName">${escapeHtml(detail.clientName ?? "—")}</td>
<td data-column="path"><code>${escapeHtml(detail.path ?? "—")}</code></td>
<td data-column="method"><span class="method">${escapeHtml(detail.method ?? "—")}</span></td>
<td data-column="status"><span class="http-status status-${Math.floor((detail.status ?? 0) / 100)}xx">${escapeHtml(detail.status ?? "—")}</span></td>
</tr>`;
}).join("") || '<tr><td class="empty" colspan="6">No external API requests yet.</td></tr>';
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);

View file

@ -26,7 +26,39 @@
<section class="panel">
<div class="panel-heading">
<div><p class="eyebrow">Activity</p><h2>Latest requests</h2></div>
<button id="refresh" type="button">Refresh</button>
<div class="refresh-actions">
<label class="toggle"><input id="autoRefresh" type="checkbox" checked> Auto-refresh</label>
<button id="refresh" type="button">Refresh</button>
</div>
</div>
<div class="request-controls" aria-label="Request controls">
<label class="search-control">Search
<input id="requestSearch" type="search" placeholder="Device, path, method…">
</label>
<label>Method
<select id="methodFilter"><option value="all">All</option></select>
</label>
<label>Status
<select id="statusFilter">
<option value="all">All</option>
<option value="2">2xx</option><option value="3">3xx</option>
<option value="4">4xx</option><option value="5">5xx</option>
</select>
</label>
<label>Sort
<select id="requestSort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="status-asc">Status: low to high</option>
<option value="status-desc">Status: high to low</option>
</select>
</label>
<label>Show
<select id="requestLimit">
<option value="10" selected>10</option><option value="25">25</option>
<option value="50">50</option><option value="100">100</option>
</select>
</label>
</div>
<fieldset class="column-picker">
<legend>Visible columns</legend>
@ -46,21 +78,23 @@
<th data-column="path">Path</th>
<th data-column="method">Method</th>
<th data-column="status">Status</th>
<th>Payload</th>
</tr>
</thead>
<tbody id="activity"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-heading">
<div><p class="eyebrow">API</p><h2>Payloads</h2></div>
<code>GET /api/payloads</code>
</div>
<pre id="payloads">Loading…</pre>
<p id="requestSummary" class="request-summary"></p>
</section>
</main>
<dialog id="payloadModal" class="payload-modal">
<div class="modal-heading">
<div><p class="eyebrow">Request payload</p><h2 id="payloadModalTitle">Payload details</h2></div>
<button id="closePayloadModal" type="button" aria-label="Close payload details">Close</button>
</div>
<dl id="payloadMeta" class="payload-meta"></dl>
<pre id="payloadDetails"></pre>
</dialog>
<script src="/app.js"></script>
</body>
</html>

View file

@ -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; }
}

View file

@ -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);