Split MQTT trace and commands pages

This commit is contained in:
Wira Basalamah
2026-06-07 03:12:18 +07:00
parent ef23b09fb7
commit 1e0f36f134
8 changed files with 681 additions and 12 deletions

View File

@ -7,7 +7,9 @@ const pages = [
"ui/admin-system-audit-logs/index.html",
"ui/settlement-batch-management/index.html",
"ui/merchant-settlement-history/index.html",
"ui/device-technical-detail/index.html"
"ui/device-technical-detail/index.html",
"ui/mqtt-trace/index.html",
"ui/config-commands/index.html"
];
const checks = [];

View File

@ -2551,13 +2551,27 @@ router.get("/dashboard/summary", requireAdminToken, async (req: Request, res: Re
router.get("/mqtt/status", requireAdminToken, async (req: Request, res: Response) => {
const limitRaw = req.query.limit;
const limit = limitRaw === undefined || limitRaw === "" ? 10 : Number(limitRaw);
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 50) : 10;
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 500) : 10;
const direction = req.query.direction === "uplink" || req.query.direction === "downlink"
? req.query.direction
: undefined;
const messageType = typeof req.query.message_type === "string" && req.query.message_type.trim()
? req.query.message_type.trim()
: undefined;
const deviceId = typeof req.query.device_id === "string" && req.query.device_id.trim()
? req.query.device_id.trim()
: undefined;
res.json(
successResponse(req, {
publisher: getMqttPublisherStatus(),
subscriber: getMqttSubscriberStatus(),
last_messages: (await listMqttMessages({ limit: safeLimit })).map(toMqttMessagePayload)
last_messages: (await listMqttMessages({
limit: safeLimit,
direction,
message_type: messageType,
device_id: deviceId
})).map(toMqttMessagePayload)
})
);
});

View File

@ -0,0 +1,327 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Config & Commands | 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; }
</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 px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover: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 bg-blue-50 px-3 text-[15px] font-semibold leading-none 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]">settings_remote</span>
Operational workflow
</div>
<h2 class="mt-1 text-2xl font-extrabold tracking-normal">Config & Commands</h2>
</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>
</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">Database</p>
<p id="database-status" class="mt-2 text-xl font-extrabold">-</p>
<p class="mt-1 text-sm text-slate-500">application persistence</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Pending Notification</p>
<p id="pending-notifications" class="mt-2 text-xl font-extrabold">0</p>
<p class="mt-1 text-sm text-slate-500">waiting to publish</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Failed Notification</p>
<p id="failed-notifications" class="mt-2 text-xl font-extrabold">0</p>
<p class="mt-1 text-sm text-slate-500">needs operator review</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Export Worker</p>
<p id="export-worker" class="mt-2 text-xl font-extrabold">-</p>
<p id="last-updated" class="mt-1 text-sm text-slate-500">-</p>
</article>
</div>
<div class="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]">
<section class="rounded-lg border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Remote Actions</h3>
<p class="mt-1 text-sm text-slate-500">Select a registered soundbox and send operational commands.</p>
</div>
<div class="space-y-5 p-5">
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Device</span>
<select id="command-device-select" class="w-full rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="">Select device</option>
</select>
</label>
<div id="selected-device-card" class="hidden rounded-lg border border-slate-200 bg-slate-50 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p id="selected-device-code" class="font-extrabold text-slate-950">-</p>
<p id="selected-device-sn" class="mono mt-1 text-xs text-slate-500">SN: -</p>
</div>
<span id="selected-device-status" class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">-</span>
</div>
<div class="mt-3 grid gap-3 text-sm md:grid-cols-3">
<div>
<p class="text-slate-500">Model</p>
<p id="selected-device-model" class="font-bold">-</p>
</div>
<div>
<p class="text-slate-500">Connection</p>
<p id="selected-device-mode" class="font-bold">-</p>
</div>
<div>
<p class="text-slate-500">Last Seen</p>
<p id="selected-device-last-seen" class="font-bold">-</p>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<button id="send-reboot-command" class="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm font-extrabold text-slate-800 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50" disabled>
<span class="material-symbols-outlined text-[20px]">restart_alt</span>
Reboot Device
</button>
<button id="open-device-detail" class="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm font-extrabold text-slate-800 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50" disabled>
<span class="material-symbols-outlined text-[20px]">open_in_new</span>
Open Device Detail
</button>
</div>
<p id="command-status" class="min-h-5 text-sm font-semibold text-slate-500"></p>
</div>
</section>
<aside class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">MQTT Runtime</h3>
</div>
<div class="space-y-3 p-5">
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3">
<span class="text-sm font-semibold text-slate-600">Publisher</span>
<span id="publisher-state" class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">-</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3">
<span class="text-sm font-semibold text-slate-600">Subscriber</span>
<span id="subscriber-state" class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">-</span>
</div>
<a class="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-blue-700 px-4 py-2.5 text-sm font-bold text-white hover:bg-blue-800" href="/ui/mqtt-trace">
<span class="material-symbols-outlined text-[20px]">lan</span>
Open MQTT Trace
</a>
</div>
</section>
</aside>
</div>
</section>
</main>
<script src="/ui/shared/admin-api.js"></script>
<script>
(function () {
const api = window.AdminUIAPI;
const state = { devices: [], observability: null, mqtt: null };
const $ = (id) => document.getElementById(id);
const normalize = (value) => String(value || "").toLowerCase().trim();
const fmt = window.AdminUIAPI?.formatDateTime || ((value) => value || "-");
function lastSeen(value) {
if (!value) return "No heartbeat";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const minutes = Math.floor((Date.now() - date.getTime()) / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes} min ago`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`;
return `${Math.floor(minutes / 1440)}d ago`;
}
function deviceLastSeen(device) {
return device?.latest_heartbeat?.received_at || device?.latest_heartbeat?.timestamp || device?.last_seen_at || null;
}
function selectedDevice() {
const id = $("command-device-select")?.value || "";
return state.devices.find((device) => device.id === id) || null;
}
function renderOps() {
const obs = state.observability || {};
const dbOk = obs.database?.status === "ok";
$("database-status").textContent = dbOk ? "OK" : (obs.database?.status || "-");
$("database-status").className = `mt-2 text-xl font-extrabold ${dbOk ? "text-emerald-700" : "text-red-700"}`;
$("pending-notifications").textContent = obs.notifications?.pending_count || 0;
$("failed-notifications").textContent = obs.notifications?.failed_count || 0;
const worker = obs.export_jobs?.worker;
$("export-worker").textContent = worker?.enabled === false ? "Disabled" : "Enabled";
$("export-worker").className = `mt-2 text-xl font-extrabold ${worker?.enabled === false ? "text-amber-700" : "text-emerald-700"}`;
$("last-updated").textContent = obs.generated_at ? `Updated ${fmt(obs.generated_at)}` : "-";
const publisher = state.mqtt?.publisher || {};
const subscriber = state.mqtt?.subscriber || {};
$("publisher-state").textContent = publisher.connected ? "Connected" : (publisher.mode || "Unknown");
$("publisher-state").className = `rounded-full px-2.5 py-1 text-xs font-bold ${publisher.connected ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"}`;
$("subscriber-state").textContent = subscriber.connected ? "Connected" : "Disconnected";
$("subscriber-state").className = `rounded-full px-2.5 py-1 text-xs font-bold ${subscriber.connected ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-red-700"}`;
}
function renderDeviceSelect() {
const select = $("command-device-select");
const current = select.value;
select.innerHTML = '<option value="">Select device</option>';
state.devices.forEach((device) => {
const option = document.createElement("option");
option.value = device.id;
option.textContent = `${device.device_code || device.serial_number || device.id} · SN ${device.serial_number || "-"}`;
select.appendChild(option);
});
if (current && state.devices.some((device) => device.id === current)) {
select.value = current;
}
renderSelectedDevice();
}
function renderSelectedDevice() {
const device = selectedDevice();
const card = $("selected-device-card");
$("send-reboot-command").disabled = !device;
$("open-device-detail").disabled = !device;
card.classList.toggle("hidden", !device);
if (!device) {
return;
}
$("selected-device-code").textContent = device.device_code || device.id || "-";
$("selected-device-sn").textContent = `SN: ${device.serial_number || "-"}`;
$("selected-device-model").textContent = device.model || "-";
$("selected-device-mode").textContent = String(device.communication_mode || "-").toUpperCase();
$("selected-device-last-seen").textContent = lastSeen(deviceLastSeen(device));
const status = normalize(device.derived_status || device.status);
$("selected-device-status").textContent = status || "-";
$("selected-device-status").className = `rounded-full px-2.5 py-1 text-xs font-bold ${status === "online" ? "bg-emerald-50 text-emerald-700" : status === "degraded" || status === "stale" ? "bg-amber-50 text-amber-700" : "bg-slate-100 text-slate-600"}`;
}
async function sendRebootCommand() {
const device = selectedDevice();
if (!device) {
return;
}
const button = $("send-reboot-command");
const status = $("command-status");
button.disabled = true;
button.classList.add("opacity-60");
status.textContent = `Sending reboot to ${device.device_code || device.serial_number || device.id}...`;
status.className = "min-h-5 text-sm font-semibold text-slate-500";
try {
const result = await api.createDeviceCommand(device.id, {
command: "device.reboot",
payload: { requested_from: "config_commands" }
});
status.textContent = `Reboot command ${result.status || "queued"} · ${result.result_payload?.topic || "-"}`;
status.className = "min-h-5 text-sm font-semibold text-emerald-700";
} catch (error) {
status.textContent = error?.message || "Unable to send reboot command.";
status.className = "min-h-5 text-sm font-semibold text-red-700";
} finally {
button.classList.remove("opacity-60");
button.disabled = !selectedDevice();
}
}
async function refresh() {
const error = $("error-banner");
error.classList.add("hidden");
try {
api.requireToken();
const [devices, obs, mqtt] = await Promise.all([
api.listDevices(),
api.getObservabilitySummary(),
api.getMqttStatus({ limit: 10 })
]);
state.devices = Array.isArray(devices) ? devices : [];
state.observability = obs || {};
state.mqtt = mqtt || {};
renderOps();
renderDeviceSelect();
} catch (err) {
if (String(err?.message || err).includes("ADMIN_AUTH_MISSING")) return;
error.textContent = err?.message || "Unable to load config and command data.";
error.classList.remove("hidden");
}
}
$("refresh-button").addEventListener("click", refresh);
$("command-device-select").addEventListener("change", () => {
$("command-status").textContent = "";
renderSelectedDevice();
});
$("send-reboot-command").addEventListener("click", sendRebootCommand);
$("open-device-detail").addEventListener("click", () => {
const device = selectedDevice();
if (device) {
window.location.href = `/ui/device-technical-detail?device_id=${encodeURIComponent(device.id)}`;
}
});
$("logout-button").addEventListener("click", () => {
api.clearToken();
window.location.href = "/ui/admin-login";
});
refresh();
window.setInterval(refresh, 30000);
})();
</script>
</body>
</html>

View File

@ -148,11 +148,11 @@
<span class="material-symbols-outlined text-[22px] shrink-0">speaker_group</span>
<span class="truncate">Registry</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#mqtt-trace">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/mqtt-trace">
<span class="material-symbols-outlined text-[22px] shrink-0">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 transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#config-commands">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/config-commands">
<span class="material-symbols-outlined text-[22px] shrink-0">settings_remote</span>
<span class="truncate">Config & Commands</span>
</a>

View File

@ -134,11 +134,11 @@
<span class="material-symbols-outlined text-[22px] shrink-0">speaker_group</span>
<span class="truncate">Registry</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#mqtt-trace">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/mqtt-trace">
<span class="material-symbols-outlined text-[22px] shrink-0">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 transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#config-commands">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/config-commands">
<span class="material-symbols-outlined text-[22px] shrink-0">settings_remote</span>
<span class="truncate">Config & Commands</span>
</a>
@ -1503,7 +1503,7 @@ Copy Command
});
notificationButton?.addEventListener("click", () => {
window.location.href = activeDeviceId
? `/ui/soundbox-ops#mqtt-trace`
? `/ui/mqtt-trace`
: "/ui/soundbox-ops";
});
calendarButton?.addEventListener("click", () => {

326
ui/mqtt-trace/index.html Normal file
View File

@ -0,0 +1,326 @@
<!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>

View File

@ -28,11 +28,11 @@
<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 px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#mqtt-trace">
<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/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/soundbox-ops#config-commands">
<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>

View File

@ -30,11 +30,11 @@
<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 px-3 text-[15px] font-semibold leading-none text-slate-600 transition-colors hover:bg-slate-100 hover:text-blue-700" href="#mqtt-trace">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 transition-colors hover:bg-slate-100 hover: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 transition-colors hover:bg-slate-100 hover:text-blue-700" href="#config-commands">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 transition-colors 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>