327 lines
17 KiB
HTML
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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>
|