Files
Qris-Soundbox/ui/mqtt-trace/index.html
2026-06-07 03:12:18 +07:00

327 lines
17 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MQTT Trace | Soundbox Ops</title>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<style>
body { font-family: Inter, Arial, sans-serif; }
.mono { font-family: "JetBrains Mono", monospace; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24; vertical-align: middle; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 999px; }
</style>
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
<aside class="fixed inset-y-0 left-0 z-50 hidden w-64 border-r border-slate-200 bg-white px-4 py-6 lg:flex lg:flex-col">
<div class="px-2">
<h1 class="text-[22px] font-extrabold leading-tight text-blue-700">Soundbox Ops</h1>
<p class="mt-1 text-[12px] font-bold uppercase leading-none text-slate-500">Monitoring Console</p>
</div>
<nav class="mt-8 flex flex-1 flex-col gap-1">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops">
<span class="material-symbols-outlined shrink-0 text-[22px]">monitor_heart</span>
<span class="truncate">Monitoring</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined shrink-0 text-[22px]">speaker_group</span>
<span class="truncate">Registry</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg bg-blue-50 px-3 text-[15px] font-semibold leading-none text-blue-700" href="/ui/mqtt-trace">
<span class="material-symbols-outlined shrink-0 text-[22px]">lan</span>
<span class="truncate">MQTT Trace</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/config-commands">
<span class="material-symbols-outlined shrink-0 text-[22px]">settings_remote</span>
<span class="truncate">Config & Commands</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-catalog">
<span class="material-symbols-outlined shrink-0 text-[22px]">category</span>
<span class="truncate">Catalog</span>
</a>
</nav>
<div class="border-t border-slate-200 pt-4">
<button id="logout-button" class="flex h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700">
<span class="material-symbols-outlined shrink-0 text-[22px]">logout</span>
<span class="truncate">Logout</span>
</button>
</div>
</aside>
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/95 px-4 py-4 backdrop-blur lg:ml-64 lg:px-8">
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<div class="flex items-center gap-2 text-sm font-semibold text-slate-500">
<span class="material-symbols-outlined text-[18px]">lan</span>
Uplink and downlink audit trail
</div>
<h2 class="mt-1 text-2xl font-extrabold tracking-normal">MQTT Trace</h2>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-72 flex-1 xl:w-96 xl:flex-none">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
<input id="search-input" class="w-full rounded-lg border-slate-200 bg-slate-50 py-2 pl-10 pr-3 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="Search topic, device, type, payload" type="search" />
</div>
<button id="refresh-button" class="inline-flex items-center gap-2 rounded-lg bg-blue-700 px-4 py-2 text-sm font-bold text-white hover:bg-blue-800">
<span class="material-symbols-outlined text-[20px]">sync</span>
Refresh
</button>
</div>
</div>
</header>
<main class="lg:ml-64">
<section class="px-4 py-6 lg:px-8">
<div id="error-banner" class="mb-4 hidden rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"></div>
<div class="grid gap-4 md:grid-cols-4">
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Broker</p>
<p id="broker-state" class="mt-2 text-xl font-extrabold">-</p>
<p id="broker-url" class="mt-1 truncate text-sm text-slate-500">-</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Subscriber</p>
<p id="subscriber-state" class="mt-2 text-xl font-extrabold">-</p>
<p id="subscriber-topics" class="mt-1 truncate text-sm text-slate-500">-</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Visible Events</p>
<p id="visible-count" class="mt-2 text-xl font-extrabold">0</p>
<p class="mt-1 text-sm text-slate-500">after filters</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Last Message</p>
<p id="last-message" class="mt-2 text-xl font-extrabold">-</p>
<p class="mt-1 text-sm text-slate-500">auto refresh every 10s</p>
</article>
</div>
<section class="mt-6 rounded-lg border border-slate-200 bg-white">
<div class="flex flex-wrap items-center gap-3 border-b border-slate-200 px-5 py-4">
<div class="flex items-center gap-2 pr-3 text-sm font-extrabold text-slate-700">
<span class="material-symbols-outlined text-[20px]">filter_list</span>
Filters
</div>
<select id="direction-filter" class="rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="">All directions</option>
<option value="uplink">Uplink</option>
<option value="downlink">Downlink</option>
</select>
<select id="type-filter" class="rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="">All message types</option>
</select>
<select id="limit-select" class="rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="100">100 rows</option>
<option value="250">250 rows</option>
<option value="500">500 rows</option>
</select>
<button id="clear-filters" class="ml-auto text-sm font-bold text-blue-700 hover:underline">Clear All</button>
</div>
<div id="trace-list" class="divide-y divide-slate-100">
<p class="px-5 py-10 text-center text-slate-500">Loading MQTT trace...</p>
</div>
</section>
</section>
</main>
<div id="payload-modal" class="fixed inset-0 z-[80] hidden items-center justify-center bg-slate-900/50 px-4">
<div class="w-full max-w-4xl overflow-hidden rounded-lg border border-slate-200 bg-white shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div>
<h3 class="text-lg font-extrabold">MQTT Payload</h3>
<p id="payload-modal-topic" class="mono mt-1 break-all text-xs text-slate-500">-</p>
</div>
<button id="payload-modal-close" class="rounded-lg p-2 text-slate-500 hover:bg-slate-100">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<pre id="payload-modal-body" class="mono max-h-[70vh] overflow-auto bg-slate-950 p-5 text-xs leading-5 text-green-300"></pre>
</div>
</div>
<script src="/ui/shared/admin-api.js"></script>
<script>
(function () {
const api = window.AdminUIAPI;
const state = { messages: [], mqtt: null };
const $ = (id) => document.getElementById(id);
const normalize = (value) => String(value || "").toLowerCase().trim();
function formatAgo(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
if (seconds < 60) return "Just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function directionMeta(direction) {
return normalize(direction) === "downlink"
? { label: "DOWNLINK", cls: "bg-blue-50 text-blue-700", icon: "south_west" }
: { label: "UPLINK", cls: "bg-emerald-50 text-emerald-700", icon: "north_east" };
}
function filteredMessages() {
const query = normalize($("search-input")?.value);
const direction = normalize($("direction-filter")?.value);
const type = $("type-filter")?.value || "";
return state.messages.filter((message) => {
const haystack = normalize([
message.id,
message.device_id,
message.topic,
message.message_type,
message.publish_status,
message.reason,
JSON.stringify(message.payload_json || {})
].join(" "));
return (!query || haystack.includes(query)) &&
(!direction || normalize(message.direction) === direction) &&
(!type || message.message_type === type);
});
}
function renderSummary() {
const publisher = state.mqtt?.publisher || {};
const subscriber = state.mqtt?.subscriber || {};
$("broker-state").textContent = publisher.connected ? "Connected" : (publisher.mode || "Unknown");
$("broker-state").className = `mt-2 text-xl font-extrabold ${publisher.connected ? "text-emerald-700" : "text-amber-700"}`;
$("broker-url").textContent = publisher.broker_url || "-";
$("subscriber-state").textContent = subscriber.connected ? "Connected" : "Disconnected";
$("subscriber-state").className = `mt-2 text-xl font-extrabold ${subscriber.connected ? "text-emerald-700" : "text-red-700"}`;
$("subscriber-topics").textContent = Array.isArray(subscriber.topics) ? subscriber.topics.join(", ") : "-";
$("visible-count").textContent = filteredMessages().length;
$("last-message").textContent = state.messages[0]?.created_at ? formatAgo(state.messages[0].created_at) : "-";
}
function renderTypeOptions() {
const select = $("type-filter");
const current = select.value;
const types = Array.from(new Set(state.messages.map((item) => item.message_type).filter(Boolean))).sort();
select.innerHTML = '<option value="">All message types</option>';
types.forEach((type) => {
const option = document.createElement("option");
option.value = type;
option.textContent = type;
select.appendChild(option);
});
if (types.includes(current)) {
select.value = current;
}
}
function renderList() {
const list = $("trace-list");
const rows = filteredMessages();
$("visible-count").textContent = rows.length;
if (!rows.length) {
list.innerHTML = '<p class="px-5 py-10 text-center text-slate-500">No MQTT messages matched the current filters.</p>';
return;
}
list.innerHTML = rows.map((message, index) => {
const meta = directionMeta(message.direction);
const payload = message.payload_json || {};
const serial = message.topic?.match(/^soundbox\/([^/]+)/)?.[1] || payload.serial_number || payload.data?.["dev-sn"] || "-";
return `
<button class="block w-full px-5 py-4 text-left hover:bg-slate-50" data-index="${index}">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-extrabold ${meta.cls}">
<span class="material-symbols-outlined text-[16px]">${meta.icon}</span>${meta.label}
</span>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">${escapeHtml(message.publish_status || "recorded")}</span>
<span class="mono text-xs text-slate-500">SN: ${escapeHtml(serial)}</span>
</div>
<p class="mono mt-3 break-all text-sm font-extrabold text-slate-800">${escapeHtml(message.topic || "-")}</p>
<p class="mt-1 text-sm text-slate-500">${escapeHtml(message.message_type || "message")} ${message.correlation_id ? `· ${escapeHtml(message.correlation_id)}` : ""}</p>
</div>
<p class="shrink-0 text-sm font-semibold text-slate-500">${formatAgo(message.created_at)}</p>
</div>
</button>
`;
}).join("");
list.querySelectorAll("[data-index]").forEach((button) => {
button.addEventListener("click", () => openPayload(rows[Number(button.dataset.index)]));
});
}
function openPayload(message) {
$("payload-modal-topic").textContent = message.topic || "-";
$("payload-modal-body").textContent = JSON.stringify(message.payload_json || {}, null, 2);
$("payload-modal").classList.remove("hidden");
$("payload-modal").classList.add("flex");
}
function closePayload() {
$("payload-modal").classList.add("hidden");
$("payload-modal").classList.remove("flex");
}
async function refresh() {
const error = $("error-banner");
error.classList.add("hidden");
try {
api.requireToken();
const data = await api.getMqttStatus({ limit: $("limit-select").value || 100 });
state.mqtt = data || {};
state.messages = Array.isArray(data?.last_messages) ? data.last_messages : [];
renderTypeOptions();
renderSummary();
renderList();
} catch (err) {
if (String(err?.message || err).includes("ADMIN_AUTH_MISSING")) return;
error.textContent = err?.message || "Unable to load MQTT trace.";
error.classList.remove("hidden");
}
}
$("refresh-button").addEventListener("click", refresh);
$("search-input").addEventListener("input", () => { renderSummary(); renderList(); });
$("direction-filter").addEventListener("change", () => { renderSummary(); renderList(); });
$("type-filter").addEventListener("change", () => { renderSummary(); renderList(); });
$("limit-select").addEventListener("change", refresh);
$("clear-filters").addEventListener("click", () => {
$("search-input").value = "";
$("direction-filter").value = "";
$("type-filter").value = "";
renderSummary();
renderList();
});
$("payload-modal-close").addEventListener("click", closePayload);
$("payload-modal").addEventListener("click", (event) => {
if (event.target === $("payload-modal")) closePayload();
});
$("logout-button").addEventListener("click", () => {
api.clearToken();
window.location.href = "/ui/admin-login";
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closePayload();
});
refresh();
window.setInterval(refresh, 10000);
})();
</script>
</body>
</html>