Add configurable API activity columns
This commit is contained in:
parent
dc45e75219
commit
bc0b47e473
4 changed files with 107 additions and 50 deletions
|
|
@ -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 `
|
||||
<div class="event">
|
||||
<span class="source">${escapeHtml(event.source)}</span>
|
||||
<span class="time">${new Date(event.timestamp).toLocaleTimeString("da-DK")}</span>
|
||||
<span class="caller">${escapeHtml(callerName)}</span>
|
||||
<span class="detail"><strong>${escapeHtml(event.action)}</strong> ${escapeHtml(JSON.stringify(detail))}</span>
|
||||
</div>`;
|
||||
}).join("") || "<p>Ingen eksterne API-hændelser endnu.</p>";
|
||||
<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) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="da">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
|
@ -11,24 +11,46 @@
|
|||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Arduino UNO R4 WiFi</p>
|
||||
<h1>Serveraktivitet</h1>
|
||||
<p>Live overblik over REST API-trafik fra Arduino-boardet.</p>
|
||||
<h1>Server activity</h1>
|
||||
<p>Live overview of REST API traffic from Arduino boards.</p>
|
||||
</div>
|
||||
<div id="health" class="status">Forbinder…</div>
|
||||
<div id="health" class="status">Connecting…</div>
|
||||
</header>
|
||||
|
||||
<section class="metrics">
|
||||
<article><span>Gemte payloads</span><strong id="payloadCount">–</strong></article>
|
||||
<article><span>API-hændelser</span><strong id="apiCount">–</strong></article>
|
||||
<article><span>Serverhændelser</span><strong id="serverCount">–</strong></article>
|
||||
<article><span>Stored payloads</span><strong id="payloadCount">–</strong></article>
|
||||
<article><span>API events</span><strong id="apiCount">–</strong></article>
|
||||
<article><span>Server events</span><strong id="serverCount">–</strong></article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-heading">
|
||||
<div><p class="eyebrow">Aktivitet</p><h2>Seneste hændelser</h2></div>
|
||||
<button id="refresh" type="button">Opdatér</button>
|
||||
<div><p class="eyebrow">Activity</p><h2>Latest requests</h2></div>
|
||||
<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 id="activity" class="activity"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
|
|
@ -36,7 +58,7 @@
|
|||
<div><p class="eyebrow">API</p><h2>Payloads</h2></div>
|
||||
<code>GET /api/payloads</code>
|
||||
</div>
|
||||
<pre id="payloads">Indlæser…</pre>
|
||||
<pre id="payloads">Loading…</pre>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue