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"),
|
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) => ({
|
||||||
"&": "&", "<": "<", ">": ">", "'": "'", '"': """,
|
"&": "&", "<": "<", ">": ">", "'": "'", '"': """,
|
||||||
})[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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue