Split MQTT trace and commands pages
This commit is contained in:
@ -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 = [];
|
||||
|
||||
@ -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)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
327
ui/config-commands/index.html
Normal file
327
ui/config-commands/index.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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
326
ui/mqtt-trace/index.html
Normal 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, "&")
|
||||
.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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user