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 @@
- Gemte payloads–
- API-hændelser–
- Serverhændelser–
+ Stored payloads–
+ API events–
+ Server events–
@@ -36,7 +58,7 @@
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();