Improve payload request dashboard
This commit is contained in:
parent
3960b4db6e
commit
bbceaeb58a
4 changed files with 184 additions and 29 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -26,8 +26,40 @@
|
|||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div><p class="eyebrow">Activity</p><h2>Latest requests</h2></div>
|
||||
<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>
|
||||
<label><input type="checkbox" data-column-toggle="deviceId" checked> Device ID</label>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue