Add configurable API activity columns

This commit is contained in:
Christian Johannesen 2026-10-24 20:19:28 +02:00
parent dc45e75219
commit bc0b47e473
4 changed files with 107 additions and 50 deletions

View file

@ -6,14 +6,44 @@ const elements = {
activity: document.querySelector("#activity"), activity: document.querySelector("#activity"),
payloads: document.querySelector("#payloads"), payloads: document.querySelector("#payloads"),
refresh: document.querySelector("#refresh"), refresh: document.querySelector("#refresh"),
columnToggles: [...document.querySelectorAll("[data-column-toggle]")],
}; };
const columnPreferenceKey = "arduino-playground-visible-columns";
function escapeHtml(value) { function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, (character) => ({ return String(value).replace(/[&<>'"]/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;", "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;",
})[character]); })[character]);
} }
function readColumnPreferences() {
try {
return JSON.parse(localStorage.getItem(columnPreferenceKey)) ?? {};
} catch {
return {};
}
}
function applyColumnPreferences() {
const preferences = readColumnPreferences();
for (const toggle of elements.columnToggles) {
const column = toggle.dataset.columnToggle;
if (column in preferences) toggle.checked = preferences[column];
document.querySelectorAll(`[data-column="${column}"]`).forEach((cell) => {
cell.hidden = !toggle.checked;
});
}
}
function saveColumnPreferences() {
const preferences = Object.fromEntries(
elements.columnToggles.map((toggle) => [toggle.dataset.columnToggle, toggle.checked]),
);
localStorage.setItem(columnPreferenceKey, JSON.stringify(preferences));
applyColumnPreferences();
}
async function refresh() { async function refresh() {
try { try {
const dashboardRequest = { headers: { "X-Monitor-Request": "dashboard" } }; const dashboardRequest = { headers: { "X-Monitor-Request": "dashboard" } };
@ -34,19 +64,20 @@ async function refresh() {
elements.apiCount.textContent = activity.filter((event) => event.source === "api").length; elements.apiCount.textContent = activity.filter((event) => event.source === "api").length;
elements.serverCount.textContent = activity.filter((event) => event.source === "server").length; elements.serverCount.textContent = activity.filter((event) => event.source === "server").length;
elements.payloads.textContent = JSON.stringify(payloads, null, 2); elements.payloads.textContent = JSON.stringify(payloads, null, 2);
elements.activity.innerHTML = activity.slice(0, 30).map((event) => { const requests = activity.filter((event) => event.source === "api");
const caller = event.detail.caller; elements.activity.innerHTML = requests.slice(0, 30).map((event) => {
const callerName = caller?.deviceId || caller?.clientName || caller?.ip || "ukendt"; const detail = event.detail;
const detail = { ...event.detail };
delete detail.caller;
return ` return `
<div class="event"> <tr>
<span class="source">${escapeHtml(event.source)}</span> <td class="time">${new Date(event.timestamp).toLocaleTimeString("en-GB")}</td>
<span class="time">${new Date(event.timestamp).toLocaleTimeString("da-DK")}</span> <td data-column="deviceId">${escapeHtml(detail.deviceId ?? "—")}</td>
<span class="caller">${escapeHtml(callerName)}</span> <td data-column="clientName">${escapeHtml(detail.clientName ?? "—")}</td>
<span class="detail"><strong>${escapeHtml(event.action)}</strong> ${escapeHtml(JSON.stringify(detail))}</span> <td data-column="path"><code>${escapeHtml(detail.path ?? "—")}</code></td>
</div>`; <td data-column="method"><span class="method">${escapeHtml(detail.method ?? "—")}</span></td>
}).join("") || "<p>Ingen eksterne API-hændelser endnu.</p>"; <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) { } catch (error) {
elements.health.textContent = "Server offline"; elements.health.textContent = "Server offline";
elements.health.className = "status"; elements.health.className = "status";
@ -54,5 +85,7 @@ async function refresh() {
} }
elements.refresh.addEventListener("click", refresh); elements.refresh.addEventListener("click", refresh);
elements.columnToggles.forEach((toggle) => toggle.addEventListener("change", saveColumnPreferences));
applyColumnPreferences();
refresh(); refresh();
setInterval(refresh, 3000); setInterval(refresh, 3000);

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="da"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -11,24 +11,46 @@
<header> <header>
<div> <div>
<p class="eyebrow">Arduino UNO R4 WiFi</p> <p class="eyebrow">Arduino UNO R4 WiFi</p>
<h1>Serveraktivitet</h1> <h1>Server activity</h1>
<p>Live overblik over REST API-trafik fra Arduino-boardet.</p> <p>Live overview of REST API traffic from Arduino boards.</p>
</div> </div>
<div id="health" class="status">Forbinder…</div> <div id="health" class="status">Connecting…</div>
</header> </header>
<section class="metrics"> <section class="metrics">
<article><span>Gemte payloads</span><strong id="payloadCount">–</strong></article> <article><span>Stored payloads</span><strong id="payloadCount">–</strong></article>
<article><span>API-hændelser</span><strong id="apiCount">–</strong></article> <article><span>API events</span><strong id="apiCount">–</strong></article>
<article><span>Serverhændelser</span><strong id="serverCount">–</strong></article> <article><span>Server events</span><strong id="serverCount">–</strong></article>
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-heading"> <div class="panel-heading">
<div><p class="eyebrow">Aktivitet</p><h2>Seneste hændelser</h2></div> <div><p class="eyebrow">Activity</p><h2>Latest requests</h2></div>
<button id="refresh" type="button">Opdatér</button> <button id="refresh" type="button">Refresh</button>
</div>
<fieldset class="column-picker">
<legend>Visible columns</legend>
<label><input type="checkbox" data-column-toggle="deviceId" checked> Device ID</label>
<label><input type="checkbox" data-column-toggle="clientName" checked> Client name</label>
<label><input type="checkbox" data-column-toggle="path" checked> Path</label>
<label><input type="checkbox" data-column-toggle="method" checked> Method</label>
<label><input type="checkbox" data-column-toggle="status" checked> Status</label>
</fieldset>
<div class="activity-wrap">
<table class="activity-table">
<thead>
<tr>
<th>Time</th>
<th data-column="deviceId">Device ID</th>
<th data-column="clientName">Client name</th>
<th data-column="path">Path</th>
<th data-column="method">Method</th>
<th data-column="status">Status</th>
</tr>
</thead>
<tbody id="activity"></tbody>
</table>
</div> </div>
<div id="activity" class="activity"></div>
</section> </section>
<section class="panel"> <section class="panel">
@ -36,7 +58,7 @@
<div><p class="eyebrow">API</p><h2>Payloads</h2></div> <div><p class="eyebrow">API</p><h2>Payloads</h2></div>
<code>GET /api/payloads</code> <code>GET /api/payloads</code>
</div> </div>
<pre id="payloads">Indlæser…</pre> <pre id="payloads">Loading…</pre>
</section> </section>
</main> </main>
<script src="/app.js"></script> <script src="/app.js"></script>

View file

@ -21,15 +21,24 @@ p { color: #91a99f; }
.metrics strong { display: block; margin-top: 8px; font-size: 2.2rem; } .metrics strong { display: block; margin-top: 8px; font-size: 2.2rem; }
.panel { padding: 24px; margin-top: 16px; } .panel { padding: 24px; margin-top: 16px; }
button { color: #e5f7ef; cursor: pointer; } button { color: #e5f7ef; cursor: pointer; }
.activity { margin-top: 18px; display: grid; gap: 8px; } .column-picker { display: flex; flex-wrap: wrap; gap: 12px 20px; margin: 20px 0 14px; padding: 12px 16px; border: 1px solid #1d3a31; border-radius: 12px; }
.event { display: grid; grid-template-columns: 75px 95px 150px 1fr; gap: 12px; padding: 12px 0; border-top: 1px solid #183129; } .column-picker legend { color: #91a99f; padding: 0 7px; font-size: .8rem; }
.source { color: #62e6ae; text-transform: uppercase; font-size: .72rem; font-weight: 800; } .column-picker label { color: #b8cec5; font-size: .85rem; cursor: pointer; }
.column-picker input { accent-color: #62e6ae; }
.activity-wrap { overflow-x: auto; }
.activity-table { width: 100%; border-collapse: collapse; }
.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; }
.time { color: #6f887e; font-variant-numeric: tabular-nums; } .time { color: #6f887e; font-variant-numeric: tabular-nums; }
.caller { color: #f5c96a; font-weight: 700; overflow-wrap: anywhere; } .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; }
[hidden] { display: none !important; }
pre { overflow: auto; color: #bfead8; background: #07110e; padding: 18px; border-radius: 12px; } pre { overflow: auto; color: #bfead8; background: #07110e; padding: 18px; border-radius: 12px; }
@media (max-width: 650px) { @media (max-width: 650px) {
header, .panel-heading { align-items: flex-start; flex-direction: column; } header, .panel-heading { align-items: flex-start; flex-direction: column; }
.metrics { grid-template-columns: 1fr; } .metrics { grid-template-columns: 1fr; }
.event { grid-template-columns: 70px 1fr; }
.event .caller, .event .detail { grid-column: 1 / -1; }
} }

View file

@ -31,6 +31,16 @@ function identifyCaller(req) {
}; };
} }
function requestDetails(req, status) {
return {
...identifyCaller(req),
method: req.method,
path: req.path,
status,
authenticated: Boolean(req.apiAuthenticated),
};
}
function isDashboardRequest(req) { function isDashboardRequest(req) {
if (req.get("x-monitor-request") === "dashboard") return true; if (req.get("x-monitor-request") === "dashboard") return true;
const referer = req.get("referer"); const referer = req.get("referer");
@ -66,11 +76,6 @@ function requireBearer(req, res, next) {
const expected = Buffer.from(bearerToken); const expected = Buffer.from(bearerToken);
const actual = Buffer.from(supplied); const actual = Buffer.from(supplied);
if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) { if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
record("api", "auth_failed", {
method: req.method,
path: req.path,
caller: identifyCaller(req),
});
return res.status(401).json({ error: "A valid bearer token is required" }); return res.status(401).json({ error: "A valid bearer token is required" });
} }
req.apiAuthenticated = true; req.apiAuthenticated = true;
@ -90,13 +95,7 @@ app.use((req, res, next) => {
!["/api/activity", "/api/health"].includes(req.path) !["/api/activity", "/api/health"].includes(req.path)
) { ) {
res.on("finish", () => { res.on("finish", () => {
record("api", "request", { record("api", "request", requestDetails(req, res.statusCode));
method: req.method,
path: req.path,
status: res.statusCode,
authenticated: Boolean(req.apiAuthenticated),
caller: identifyCaller(req),
});
}); });
} }
next(); next();
@ -126,7 +125,6 @@ app.post("/api/payloads", requireBearer, async (req, res, next) => {
}; };
payloads.push(payload); payloads.push(payload);
await persistPayloads(); await persistPayloads();
record("api", "created", { id: payload.id, topic: payload.topic, caller: identifyCaller(req) });
res.status(201).json(payload); res.status(201).json(payload);
} catch (error) { } catch (error) {
next(error); next(error);
@ -144,11 +142,6 @@ app.patch("/api/payloads/:id", requireBearer, async (req, res, next) => {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await persistPayloads(); await persistPayloads();
record("api", "updated", {
id: req.params.id,
topic: payloads[index].topic,
caller: identifyCaller(req),
});
res.json(payloads[index]); res.json(payloads[index]);
} catch (error) { } catch (error) {
next(error); next(error);
@ -161,7 +154,6 @@ app.delete("/api/payloads/:id", requireBearer, async (req, res, next) => {
if (index === -1) return res.status(404).json({ error: "Payload not found" }); if (index === -1) return res.status(404).json({ error: "Payload not found" });
const [removed] = payloads.splice(index, 1); const [removed] = payloads.splice(index, 1);
await persistPayloads(); await persistPayloads();
record("api", "deleted", { id: removed.id, topic: removed.topic, caller: identifyCaller(req) });
res.status(204).end(); res.status(204).end();
} catch (error) { } catch (error) {
next(error); next(error);
@ -174,8 +166,9 @@ app.use(express.static(publicDir));
app.get(/.*/, (_req, res) => res.sendFile(path.join(publicDir, "index.html"))); app.get(/.*/, (_req, res) => res.sendFile(path.join(publicDir, "index.html")));
app.use((error, req, res, _next) => { app.use((error, req, res, _next) => {
record("api", "error", { method: req.method, path: req.path, message: error.message }); const status = error.type === "entity.parse.failed" ? 400 : 500;
res.status(error.type === "entity.parse.failed" ? 400 : 500).json({ error: error.message }); record("api", "error", { ...requestDetails(req, status), message: error.message });
res.status(status).json({ error: error.message });
}); });
await loadPayloads(); await loadPayloads();