From bc0b47e4736e20981fa6ff7eb725d3d10e2a1a59 Mon Sep 17 00:00:00 2001 From: Christian Johannesen Date: Sat, 24 Oct 2026 20:19:28 +0200 Subject: [PATCH] Add configurable API activity columns --- server/public/app.js | 57 +++++++++++++++++++++++++++++++--------- server/public/index.html | 44 +++++++++++++++++++++++-------- server/public/styles.css | 21 ++++++++++----- server/src/server.js | 35 ++++++++++-------------- 4 files changed, 107 insertions(+), 50 deletions(-) diff --git a/server/public/app.js b/server/public/app.js index c4b159e..d8122ac 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -6,14 +6,44 @@ const elements = { activity: document.querySelector("#activity"), payloads: document.querySelector("#payloads"), refresh: document.querySelector("#refresh"), + columnToggles: [...document.querySelectorAll("[data-column-toggle]")], }; +const columnPreferenceKey = "arduino-playground-visible-columns"; + function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (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() { try { 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.serverCount.textContent = activity.filter((event) => event.source === "server").length; elements.payloads.textContent = JSON.stringify(payloads, null, 2); - elements.activity.innerHTML = activity.slice(0, 30).map((event) => { - const caller = event.detail.caller; - const callerName = caller?.deviceId || caller?.clientName || caller?.ip || "ukendt"; - const detail = { ...event.detail }; - delete detail.caller; + const requests = activity.filter((event) => event.source === "api"); + elements.activity.innerHTML = requests.slice(0, 30).map((event) => { + const detail = event.detail; return ` -
- ${escapeHtml(event.source)} - ${new Date(event.timestamp).toLocaleTimeString("da-DK")} - ${escapeHtml(callerName)} - ${escapeHtml(event.action)} ${escapeHtml(JSON.stringify(detail))} -
`; - }).join("") || "

Ingen eksterne API-hændelser endnu.

"; + + ${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) { elements.health.textContent = "Server offline"; elements.health.className = "status"; @@ -54,5 +85,7 @@ async function refresh() { } elements.refresh.addEventListener("click", refresh); +elements.columnToggles.forEach((toggle) => toggle.addEventListener("change", saveColumnPreferences)); +applyColumnPreferences(); refresh(); setInterval(refresh, 3000); diff --git a/server/public/index.html b/server/public/index.html index 32fda60..c7ce8ad 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -1,5 +1,5 @@ - + @@ -11,24 +11,46 @@

Arduino UNO R4 WiFi

-

Serveraktivitet

-

Live overblik over REST API-trafik fra Arduino-boardet.

+

Server activity

+

Live overview of REST API traffic from Arduino boards.

-
Forbinder…
+
Connecting…
-
Gemte payloads–
-
API-hændelser–
-
Serverhændelser–
+
Stored payloads–
+
API events–
+
Server events–
-

Aktivitet

Seneste hændelser

- +

Activity

Latest requests

+ +
+
+ Visible columns + + + + + +
+
+ + + + + + + + + + + + +
TimeDevice IDClient namePathMethodStatus
-
@@ -36,7 +58,7 @@

API

Payloads

GET /api/payloads -
Indlæser…
+
Loading…
diff --git a/server/public/styles.css b/server/public/styles.css index eb51044..9e6e908 100644 --- a/server/public/styles.css +++ b/server/public/styles.css @@ -21,15 +21,24 @@ 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; } -.activity { margin-top: 18px; display: grid; gap: 8px; } -.event { display: grid; grid-template-columns: 75px 95px 150px 1fr; gap: 12px; padding: 12px 0; border-top: 1px solid #183129; } -.source { color: #62e6ae; text-transform: uppercase; font-size: .72rem; font-weight: 800; } +.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; } +.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; } -.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; } @media (max-width: 650px) { header, .panel-heading { align-items: flex-start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } - .event { grid-template-columns: 70px 1fr; } - .event .caller, .event .detail { grid-column: 1 / -1; } } diff --git a/server/src/server.js b/server/src/server.js index 8b3e6e4..80bf29a 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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) { if (req.get("x-monitor-request") === "dashboard") return true; const referer = req.get("referer"); @@ -66,11 +76,6 @@ function requireBearer(req, res, next) { const expected = Buffer.from(bearerToken); const actual = Buffer.from(supplied); 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" }); } req.apiAuthenticated = true; @@ -90,13 +95,7 @@ app.use((req, res, next) => { !["/api/activity", "/api/health"].includes(req.path) ) { res.on("finish", () => { - record("api", "request", { - method: req.method, - path: req.path, - status: res.statusCode, - authenticated: Boolean(req.apiAuthenticated), - caller: identifyCaller(req), - }); + record("api", "request", requestDetails(req, res.statusCode)); }); } next(); @@ -126,7 +125,6 @@ app.post("/api/payloads", requireBearer, async (req, res, next) => { }; payloads.push(payload); await persistPayloads(); - record("api", "created", { id: payload.id, topic: payload.topic, caller: identifyCaller(req) }); res.status(201).json(payload); } catch (error) { next(error); @@ -144,11 +142,6 @@ app.patch("/api/payloads/:id", requireBearer, async (req, res, next) => { updatedAt: new Date().toISOString(), }; await persistPayloads(); - record("api", "updated", { - id: req.params.id, - topic: payloads[index].topic, - caller: identifyCaller(req), - }); res.json(payloads[index]); } catch (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" }); const [removed] = payloads.splice(index, 1); await persistPayloads(); - record("api", "deleted", { id: removed.id, topic: removed.topic, caller: identifyCaller(req) }); res.status(204).end(); } catch (error) { next(error); @@ -174,8 +166,9 @@ app.use(express.static(publicDir)); app.get(/.*/, (_req, res) => res.sendFile(path.join(publicDir, "index.html"))); app.use((error, req, res, _next) => { - record("api", "error", { method: req.method, path: req.path, message: error.message }); - res.status(error.type === "entity.parse.failed" ? 400 : 500).json({ error: error.message }); + const status = error.type === "entity.parse.failed" ? 400 : 500; + record("api", "error", { ...requestDetails(req, status), message: error.message }); + res.status(status).json({ error: error.message }); }); await loadPayloads();