diff --git a/server/public/app.js b/server/public/app.js
index d8122ac..9ec1a68 100644
--- a/server/public/app.js
+++ b/server/public/app.js
@@ -4,12 +4,25 @@ const elements = {
apiCount: document.querySelector("#apiCount"),
serverCount: document.querySelector("#serverCount"),
activity: document.querySelector("#activity"),
- payloads: document.querySelector("#payloads"),
refresh: document.querySelector("#refresh"),
+ autoRefresh: document.querySelector("#autoRefresh"),
+ requestSearch: document.querySelector("#requestSearch"),
+ methodFilter: document.querySelector("#methodFilter"),
+ statusFilter: document.querySelector("#statusFilter"),
+ requestSort: document.querySelector("#requestSort"),
+ requestLimit: document.querySelector("#requestLimit"),
+ requestSummary: document.querySelector("#requestSummary"),
+ payloadModal: document.querySelector("#payloadModal"),
+ payloadModalTitle: document.querySelector("#payloadModalTitle"),
+ payloadMeta: document.querySelector("#payloadMeta"),
+ payloadDetails: document.querySelector("#payloadDetails"),
+ closePayloadModal: document.querySelector("#closePayloadModal"),
columnToggles: [...document.querySelectorAll("[data-column-toggle]")],
};
const columnPreferenceKey = "arduino-playground-visible-columns";
+const autoRefreshPreferenceKey = "arduino-playground-auto-refresh";
+let requests = [];
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, (character) => ({
@@ -44,7 +57,71 @@ function saveColumnPreferences() {
applyColumnPreferences();
}
+function updateMethodFilter() {
+ const selected = elements.methodFilter.value;
+ const methods = [...new Set(requests.map((event) => event.detail.method).filter(Boolean))].sort();
+ elements.methodFilter.innerHTML = ''
+ + methods.map((method) => ``).join("");
+ elements.methodFilter.value = methods.includes(selected) ? selected : "all";
+}
+
+function filteredRequests() {
+ const query = elements.requestSearch.value.trim().toLocaleLowerCase();
+ const method = elements.methodFilter.value;
+ const statusClass = elements.statusFilter.value;
+ const sort = elements.requestSort.value;
+
+ return requests.filter((event) => {
+ const detail = event.detail;
+ if (method !== "all" && detail.method !== method) return false;
+ if (statusClass !== "all" && Math.floor((detail.status ?? 0) / 100) !== Number(statusClass)) return false;
+ return !query || JSON.stringify(event).toLocaleLowerCase().includes(query);
+ }).sort((left, right) => {
+ if (sort === "oldest") return new Date(left.timestamp) - new Date(right.timestamp);
+ if (sort === "status-asc") return (left.detail.status ?? 0) - (right.detail.status ?? 0);
+ if (sort === "status-desc") return (right.detail.status ?? 0) - (left.detail.status ?? 0);
+ return new Date(right.timestamp) - new Date(left.timestamp);
+ });
+}
+
+function openPayloadModal(event) {
+ const detail = event.detail;
+ if (!detail.payload) return;
+ elements.payloadModalTitle.textContent = `${detail.method} ${detail.path}`;
+ elements.payloadMeta.innerHTML = `
+
Time${escapeHtml(new Date(event.timestamp).toLocaleString("en-GB"))}
+ Device${escapeHtml(detail.deviceId ?? "—")}
+ Status${escapeHtml(detail.status ?? "—")}`;
+ elements.payloadDetails.textContent = JSON.stringify(detail.payload, null, 2);
+ elements.payloadModal.showModal();
+}
+
+function renderRequests() {
+ const matching = filteredRequests();
+ const limit = Number(elements.requestLimit.value);
+ const visible = matching.slice(0, limit);
+
+ elements.activity.innerHTML = visible.map((event) => {
+ const detail = event.detail;
+ const hasPayload = Boolean(detail.payload);
+ return `
+
+ | ${new Date(event.timestamp).toLocaleString("en-GB")} |
+ ${escapeHtml(detail.deviceId ?? "—")} |
+ ${escapeHtml(detail.clientName ?? "—")} |
+ ${escapeHtml(detail.path ?? "—")} |
+ ${escapeHtml(detail.method ?? "—")} |
+ ${escapeHtml(detail.status ?? "—")} |
+ ${hasPayload ? '' : "—"} |
+
`;
+ }).join("") || '| No matching requests. |
';
+
+ elements.requestSummary.textContent = `Showing ${visible.length} of ${matching.length} matching requests (${requests.length} total).`;
+ applyColumnPreferences();
+}
+
async function refresh() {
+ elements.refresh.disabled = true;
try {
const dashboardRequest = { headers: { "X-Monitor-Request": "dashboard" } };
const [healthResponse, payloadResponse, activityResponse] = await Promise.all([
@@ -58,34 +135,51 @@ async function refresh() {
const [health, payloads, activity] = await Promise.all([
healthResponse.json(), payloadResponse.json(), activityResponse.json(),
]);
- elements.health.textContent = "API online";
+ elements.health.textContent = `API online · ${new Date(health.timestamp).toLocaleTimeString("en-GB")}`;
elements.health.className = "status online";
elements.payloadCount.textContent = payloads.length;
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);
- const requests = activity.filter((event) => event.source === "api");
- elements.activity.innerHTML = requests.slice(0, 30).map((event) => {
- const detail = event.detail;
- return `
-
- | ${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) {
+ requests = activity.filter((event) => event.source === "api");
+ updateMethodFilter();
+ renderRequests();
+ } catch {
elements.health.textContent = "Server offline";
elements.health.className = "status";
+ } finally {
+ elements.refresh.disabled = false;
}
}
elements.refresh.addEventListener("click", refresh);
elements.columnToggles.forEach((toggle) => toggle.addEventListener("change", saveColumnPreferences));
+[elements.requestSearch, elements.methodFilter, elements.statusFilter, elements.requestSort, elements.requestLimit]
+ .forEach((control) => control.addEventListener("input", renderRequests));
+elements.activity.addEventListener("click", (event) => {
+ const row = event.target.closest("[data-request-id]");
+ const request = requests.find((item) => item.id === row?.dataset.requestId);
+ if (request) openPayloadModal(request);
+});
+elements.activity.addEventListener("keydown", (event) => {
+ if (!["Enter", " "].includes(event.key)) return;
+ const row = event.target.closest("[data-request-id]");
+ const request = requests.find((item) => item.id === row?.dataset.requestId);
+ if (request?.detail.payload) {
+ event.preventDefault();
+ openPayloadModal(request);
+ }
+});
+elements.closePayloadModal.addEventListener("click", () => elements.payloadModal.close());
+elements.payloadModal.addEventListener("click", (event) => {
+ if (event.target === elements.payloadModal) elements.payloadModal.close();
+});
+elements.autoRefresh.addEventListener("change", () => {
+ localStorage.setItem(autoRefreshPreferenceKey, String(elements.autoRefresh.checked));
+});
+
+elements.autoRefresh.checked = localStorage.getItem(autoRefreshPreferenceKey) !== "false";
applyColumnPreferences();
refresh();
-setInterval(refresh, 3000);
+setInterval(() => {
+ if (elements.autoRefresh.checked && !document.hidden) refresh();
+}, 3000);
diff --git a/server/public/index.html b/server/public/index.html
index c7ce8ad..f39a202 100644
--- a/server/public/index.html
+++ b/server/public/index.html
@@ -26,7 +26,39 @@
-
-
+