Files
Qris-Soundbox/ui/soundbox-ops/index.html
2026-06-08 16:09:24 +07:00

713 lines
38 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soundbox Monitoring | Soundbox Ops</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></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; overflow-x: hidden; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24; vertical-align: middle; }
.mono { font-family: "JetBrains Mono", monospace; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 999px; }
</style>
</head>
<body id="top" 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 bg-blue-50 px-3 text-[15px] font-semibold leading-none text-blue-700 transition-colors" 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 transition-colors 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 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="/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 transition-colors 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 transition-colors 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]">cell_tower</span>
Live operations
</div>
<h2 class="mt-1 text-2xl font-extrabold tracking-normal text-slate-950">Soundbox Monitoring</h2>
</div>
<div class="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center xl:w-auto">
<div class="relative w-full min-w-0 sm:flex-[1_1_240px] 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-600 focus:ring-blue-600" placeholder="Search code, serial, model, merchant" type="search" />
</div>
<select id="status-filter" class="w-full min-w-0 rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-600 focus:ring-blue-600 sm:flex-[1_1_150px] xl:w-auto xl:flex-none">
<option value="">All status</option>
<option value="online">Online</option>
<option value="warning">Stale/Degraded</option>
<option value="degraded">Degraded</option>
<option value="stale">Stale</option>
<option value="offline">Offline</option>
</select>
<button id="refresh-button" class="inline-flex w-full flex-none items-center justify-center gap-2 rounded-lg bg-blue-700 px-4 py-2 text-sm font-bold text-white hover:bg-blue-800 sm:w-auto">
<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-2 xl:grid-cols-5">
<article id="kpi-total-card" class="rounded-lg border border-slate-200 bg-white p-4">
<div class="flex items-center justify-between">
<p class="text-xs font-bold uppercase text-slate-500">Total Soundbox</p>
<span class="material-symbols-outlined text-blue-700">speaker_group</span>
</div>
<p id="kpi-total" class="mt-3 text-3xl font-extrabold">0</p>
<p class="mt-1 text-sm text-slate-500">registered devices</p>
</article>
<article id="kpi-online-card" class="cursor-pointer rounded-lg border border-slate-200 bg-white p-4 transition hover:border-emerald-200 hover:bg-emerald-50/30">
<div class="flex items-center justify-between">
<p class="text-xs font-bold uppercase text-slate-500">Online</p>
<span class="material-symbols-outlined text-emerald-600">check_circle</span>
</div>
<p id="kpi-online" class="mt-3 text-3xl font-extrabold text-emerald-700">0</p>
<p id="kpi-online-rate" class="mt-1 text-sm text-slate-500">0% online rate</p>
</article>
<article id="kpi-warning-card" class="cursor-pointer rounded-lg border border-slate-200 bg-white p-4 transition hover:border-amber-200 hover:bg-amber-50/30">
<div class="flex items-center justify-between">
<p class="text-xs font-bold uppercase text-slate-500">Stale/Degraded</p>
<span class="material-symbols-outlined text-amber-600">running_with_errors</span>
</div>
<p id="kpi-warning" class="mt-3 text-3xl font-extrabold text-amber-700">0</p>
<p id="kpi-warning-detail" class="mt-1 text-sm text-slate-500">0 stale · 0 degraded</p>
</article>
<article id="kpi-offline-card" class="cursor-pointer rounded-lg border border-slate-200 bg-white p-4 transition hover:border-slate-300 hover:bg-slate-100/60">
<div class="flex items-center justify-between">
<p class="text-xs font-bold uppercase text-slate-500">Offline</p>
<span class="material-symbols-outlined text-slate-500">cloud_off</span>
</div>
<p id="kpi-offline" class="mt-3 text-3xl font-extrabold text-slate-700">0</p>
<p id="kpi-offline-detail" class="mt-1 text-sm text-slate-500">no recent heartbeat</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<div class="flex items-center justify-between">
<p class="text-xs font-bold uppercase text-slate-500">MQTT</p>
<span id="mqtt-icon" class="material-symbols-outlined text-slate-500">hub</span>
</div>
<p id="kpi-mqtt" class="mt-3 text-xl font-extrabold">Checking</p>
<p id="kpi-mqtt-detail" class="mt-1 break-all text-sm text-slate-500">broker state</p>
</article>
</div>
<div class="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(360px,0.8fr)]">
<section id="fleet-status" class="rounded-lg border border-slate-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4">
<div>
<h3 class="text-lg font-extrabold">Fleet Status</h3>
<p id="fleet-subtitle" class="mt-1 text-sm text-slate-500">Loading soundbox fleet...</p>
</div>
<a class="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm font-bold text-slate-700 hover:bg-slate-50" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined text-[18px]">table</span>
Registry
</a>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-bold uppercase text-slate-500">
<tr>
<th class="px-5 py-3">Serial Number</th>
<th class="px-5 py-3">Merchant</th>
<th class="px-5 py-3">Mode</th>
<th class="px-5 py-3">Health</th>
<th class="px-5 py-3 text-right">Last Seen</th>
</tr>
</thead>
<tbody id="device-table" class="divide-y divide-slate-100">
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">Loading devices...</td></tr>
</tbody>
</table>
</div>
</section>
<aside class="space-y-6">
<section id="config-commands" class="rounded-lg border border-slate-200 bg-white transition-shadow">
<div class="border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Config & Commands</h3>
<p id="ops-generated" class="mt-1 text-sm text-slate-500">Broker, config worker, and notification state</p>
</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">Database</span>
<span id="database-status" 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">Pending notification</span>
<span id="pending-notifications" class="text-sm font-extrabold">0</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">Failed notification</span>
<span id="failed-notifications" class="text-sm font-extrabold">0</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">Export worker</span>
<span id="export-worker" class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">-</span>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<p class="text-sm font-extrabold text-slate-950">Remote Actions</p>
<p class="mt-0.5 text-xs text-slate-500">Send operational command to a selected soundbox</p>
</div>
<span class="material-symbols-outlined text-blue-700">settings_remote</span>
</div>
<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-600 focus:ring-blue-600">
<option value="">Select device</option>
</select>
</label>
<button id="send-reboot-command" class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 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="send-poweroff-command" class="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-extrabold text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50" disabled>
<span class="material-symbols-outlined text-[20px]">power_settings_new</span>
Power Off Device
</button>
<p id="command-status" class="mt-2 min-h-5 text-xs font-semibold text-slate-500"></p>
</div>
</div>
</section>
<section id="mqtt-trace" class="rounded-lg border border-slate-200 bg-white transition-shadow">
<div class="border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Recent MQTT Trace</h3>
<p class="mt-1 text-sm text-slate-500">latest uplink and downlink records</p>
</div>
<div id="mqtt-list" class="max-h-[420px] overflow-auto p-3">
<p class="px-2 py-4 text-center text-sm text-slate-500">Loading MQTT trace...</p>
</div>
</section>
</aside>
</div>
</section>
</main>
<script src="/ui/shared/admin-api.js"></script>
<script>
(function () {
const api = window.AdminUIAPI;
const state = { devices: [], merchants: new Map(), mqtt: null, observability: null };
const isPreviewMode =
new URLSearchParams(window.location.search).get("preview") === "1" ||
((window.location.hostname === "127.0.0.1" || window.location.hostname === "localhost") && window.location.port === "4173");
const $ = (id) => document.getElementById(id);
const normalize = (value) => String(value || "").toLowerCase().trim();
const fmt = window.AdminUIAPI?.formatDateTime || ((value) => value || "-");
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[char]));
const reasonLabels = {
no_heartbeat: "No heartbeat",
offline_threshold_exceeded: "Offline threshold",
stale_threshold_exceeded: "Stale heartbeat",
low_signal: "Low signal",
low_battery: "Low battery"
};
function lastSeen(value) {
if (!value) return "No heartbeat";
if (typeof value === "object") {
return lastSeen(value.received_at || value.timestamp || value.created_at || value.updated_at);
}
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 statusMeta(value) {
const status = normalize(value);
if (status === "online") return { label: "Online", badge: "bg-emerald-50 text-emerald-700 border-emerald-200", dot: "bg-emerald-500" };
if (status === "degraded") return { label: "Degraded", badge: "bg-amber-50 text-amber-700 border-amber-200", dot: "bg-amber-500" };
if (status === "stale") return { label: "Stale", badge: "bg-amber-50 text-amber-700 border-amber-200", dot: "bg-amber-500" };
return { label: "Offline", badge: "bg-slate-100 text-slate-600 border-slate-200", dot: "bg-slate-400" };
}
function modeMeta(value) {
const mode = normalize(value);
if (mode === "mqtt") return { label: "MQTT", icon: "cell_tower" };
if (mode === "api") return { label: "API", icon: "hub" };
if (mode === "static") return { label: "Static", icon: "qr_code_2" };
return { label: value || "Unknown", icon: "settings_input_component" };
}
function healthLabel(device) {
const score = device.health_summary?.score;
if (typeof score === "number") return `${score}%`;
return device.latest_heartbeat ? "Tracked" : "Unknown";
}
function healthTone(score) {
if (typeof score !== "number") return "bg-slate-200";
if (score >= 85) return "bg-emerald-500";
if (score >= 60) return "bg-amber-500";
return "bg-red-500";
}
function healthReasons(device) {
const reasons = device.health_summary?.reasons;
if (!Array.isArray(reasons) || !reasons.length) return "No active warning";
return reasons.map((item) => reasonLabels[item] || item).join(", ");
}
function metricValue(value, suffix = "") {
return typeof value === "number" && Number.isFinite(value) ? `${value}${suffix}` : "-";
}
function merchantName(device) {
const id = device.binding_summary?.merchant_id || device.active_binding?.merchant_id;
return state.merchants.get(id) || "Unassigned";
}
function filteredDevices() {
const query = normalize($("search-input")?.value);
const status = normalize($("status-filter")?.value);
return state.devices.filter((device) => {
const text = [
device.id,
device.device_code,
device.serial_number,
device.model,
device.vendor,
merchantName(device)
].map(normalize).join(" ");
const matchesQuery = !query || text.includes(query);
const deviceStatus = normalize(device.derived_status);
const matchesStatus = !status ||
(status === "warning" ? ["stale", "degraded"].includes(deviceStatus) : deviceStatus === status);
return matchesQuery && matchesStatus;
});
}
function renderKpis() {
const total = state.devices.length;
const online = state.devices.filter((item) => normalize(item.derived_status) === "online").length;
const stale = state.devices.filter((item) => normalize(item.derived_status) === "stale").length;
const degraded = state.devices.filter((item) => normalize(item.derived_status) === "degraded").length;
const warning = stale + degraded;
const offline = state.devices.filter((item) => !["online", "stale", "degraded"].includes(normalize(item.derived_status))).length;
$("kpi-total").textContent = total;
$("kpi-online").textContent = online;
$("kpi-warning").textContent = warning;
$("kpi-offline").textContent = offline;
$("kpi-online-rate").textContent = `${total ? Math.round((online / total) * 100) : 0}% online rate`;
$("kpi-warning-detail").textContent = `${stale} stale · ${degraded} degraded`;
$("kpi-offline-detail").textContent = total ? `${Math.round((offline / total) * 100)}% of fleet` : "no recent heartbeat";
const publisher = state.mqtt?.publisher || {};
const subscriber = state.mqtt?.subscriber || {};
const connected = publisher.connected || subscriber.connected;
$("kpi-mqtt").textContent = connected ? "Connected" : (publisher.mode === "broker" ? "Configured" : "Simulator");
$("kpi-mqtt").className = `mt-3 text-xl font-extrabold ${connected ? "text-emerald-700" : "text-amber-700"}`;
$("mqtt-icon").className = `material-symbols-outlined ${connected ? "text-emerald-600" : "text-amber-600"}`;
$("kpi-mqtt-detail").textContent = publisher.broker_url || publisher.mode || "broker state";
}
function renderTable() {
const rows = filteredDevices();
$("fleet-subtitle").textContent = `${rows.length} visible of ${state.devices.length} registered soundbox units`;
const table = $("device-table");
if (!rows.length) {
table.innerHTML = '<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">No soundbox matched the current filters.</td></tr>';
return;
}
table.innerHTML = rows.slice(0, 50).map((device) => {
const status = statusMeta(device.derived_status);
const mode = modeMeta(device.communication_mode);
const score = device.health_summary?.score;
const code = device.device_code || device.id || "-";
const serial = device.serial_number || device.device_code || device.id || "-";
const heartbeat = device.latest_heartbeat || {};
const signal = heartbeat.network_strength;
const battery = heartbeat.battery_level;
const scoreWidth = typeof score === "number" ? Math.max(4, Math.min(100, score)) : 0;
const detailUrl = `/ui/device-technical-detail?device_id=${encodeURIComponent(device.id)}`;
return `
<tr class="hover:bg-slate-50">
<td class="px-5 py-4">
<a class="mono font-bold text-slate-950 hover:text-blue-700" href="${detailUrl}">${esc(serial)}</a>
<div class="mono mt-1 text-xs text-slate-500">Code: ${esc(code)}</div>
</td>
<td class="px-5 py-4">
<div class="font-semibold">${esc(merchantName(device))}</div>
<div class="mt-1 text-xs text-slate-500">${esc(device.model || "Unknown model")}</div>
</td>
<td class="px-5 py-4">
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-700">
<span class="material-symbols-outlined text-[16px]">${esc(mode.icon)}</span>${esc(mode.label)}
</span>
</td>
<td class="px-5 py-4">
<div class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-bold ${status.badge}">
<span class="h-1.5 w-1.5 rounded-full ${status.dot}"></span>${esc(status.label)}
</div>
<div class="mt-2 flex items-center gap-2">
<div class="h-1.5 w-20 overflow-hidden rounded-full bg-slate-200">
<div class="h-full ${healthTone(score)}" style="width:${scoreWidth}%"></div>
</div>
<span class="text-xs font-bold text-slate-600">${esc(healthLabel(device))}</span>
</div>
<div class="mt-1 max-w-[180px] truncate text-xs text-slate-500" title="${esc(healthReasons(device))}">${esc(healthReasons(device))}</div>
<div class="mt-1 text-[11px] font-semibold text-slate-500">Signal ${esc(metricValue(signal, typeof signal === "number" && signal < 0 ? " dBm" : ""))} · Battery ${esc(metricValue(battery, "%"))}</div>
</td>
<td class="px-5 py-4 text-right text-slate-600">${esc(lastSeen(deviceLastSeen(device)))}</td>
</tr>
`;
}).join("");
}
function renderOps() {
const obs = state.observability || {};
const dbOk = obs.database?.status === "ok";
$("ops-generated").textContent = obs.generated_at ? `Updated ${fmt(obs.generated_at)}` : "Waiting for summary";
$("database-status").textContent = dbOk ? "OK" : (obs.database?.status || "-");
$("database-status").className = `rounded-full px-2.5 py-1 text-xs font-bold ${dbOk ? "bg-emerald-50 text-emerald-700" : "bg-red-50 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 = `rounded-full px-2.5 py-1 text-xs font-bold ${worker?.enabled === false ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}`;
}
function renderCommandDevices() {
const select = $("command-device-select");
const rebootButton = $("send-reboot-command");
const poweroffButton = $("send-poweroff-command");
if (!select || !rebootButton || !poweroffButton) {
return;
}
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.serial_number || device.device_code || device.id} · ${device.model || device.device_code || "soundbox"}`;
select.appendChild(option);
});
if (current && state.devices.some((device) => device.id === current)) {
select.value = current;
}
rebootButton.disabled = !select.value;
poweroffButton.disabled = !select.value;
}
function renderMqtt() {
const list = $("mqtt-list");
const messages = Array.isArray(state.mqtt?.last_messages) ? state.mqtt.last_messages : [];
if (!messages.length) {
list.innerHTML = '<p class="px-2 py-4 text-center text-sm text-slate-500">No MQTT trace yet.</p>';
return;
}
list.innerHTML = messages.slice(0, 20).map((message) => {
const direction = normalize(message.direction);
const color = direction === "downlink" ? "text-blue-700 bg-blue-50" : "text-emerald-700 bg-emerald-50";
return `
<div class="rounded-lg px-2 py-3 hover:bg-slate-50">
<div class="flex items-center justify-between gap-3">
<span class="rounded-full px-2 py-0.5 text-[11px] font-bold uppercase ${color}">${esc(message.direction || "trace")}</span>
<span class="text-xs text-slate-500">${esc(lastSeen(message.created_at))}</span>
</div>
<div class="mono mt-2 break-all text-xs font-semibold text-slate-700">${esc(message.topic || "-")}</div>
<div class="mt-1 text-xs text-slate-500">${esc(message.message_type || "message")} · ${esc(message.publish_status || "recorded")}</div>
</div>
`;
}).join("");
}
function renderAll() {
renderKpis();
renderTable();
renderOps();
renderCommandDevices();
renderMqtt();
}
function focusSection(id) {
const target = document.getElementById(id);
if (!target) {
return;
}
target.scrollIntoView({ behavior: "smooth", block: "start" });
target.classList.add("ring-2", "ring-blue-500", "ring-offset-2");
window.setTimeout(() => {
target.classList.remove("ring-2", "ring-blue-500", "ring-offset-2");
}, 1400);
}
function loadPreviewData() {
const now = Date.now();
state.merchants = new Map([
["merchant_mbiz", "MBiz Jakarta"],
["merchant_demo", "Demo Mart"],
["merchant_qf100", "QF100 Pilot Store"]
]);
state.devices = [
{
id: "dev_qf100_static_01",
device_code: "QF100-STATIC-01",
serial_number: "SN-QF100-0001",
vendor: "QF100",
model: "QF100 Static",
communication_mode: "mqtt",
derived_status: "online",
latest_heartbeat: {
received_at: new Date(now - 45 * 1000).toISOString(),
network_strength: 72,
battery_level: 86
},
health_summary: { score: 98, reasons: [], age_seconds: 45 },
binding_summary: { merchant_id: "merchant_qf100" }
},
{
id: "dev_qf100_dynamic_01",
device_code: "QF100-DYN-01",
serial_number: "SN-QF100-0002",
vendor: "QF100",
model: "QF100 Dynamic",
communication_mode: "mqtt",
derived_status: "stale",
latest_heartbeat: {
received_at: new Date(now - 42 * 60 * 1000).toISOString(),
network_strength: 54,
battery_level: 61
},
health_summary: { score: 65, reasons: ["stale_threshold_exceeded"], age_seconds: 2520 },
binding_summary: { merchant_id: "merchant_mbiz" }
},
{
id: "dev_counter_03",
device_code: "SND-COUNTER-03",
serial_number: "SN-DEMO-0003",
vendor: "Generic",
model: "Soundbox V2",
communication_mode: "api",
derived_status: "degraded",
latest_heartbeat: {
received_at: new Date(now - 54 * 1000).toISOString(),
network_strength: 28,
battery_level: 17
},
health_summary: { score: 85, reasons: ["low_signal", "low_battery"], age_seconds: 54 },
binding_summary: { merchant_id: "merchant_demo" }
},
{
id: "dev_stock_04",
device_code: "SND-STOCK-04",
serial_number: "SN-DEMO-0004",
vendor: "Generic",
model: "Unassigned Stock",
communication_mode: "static",
derived_status: "offline",
latest_heartbeat: null,
health_summary: { score: 0, reasons: ["no_heartbeat"], age_seconds: null },
binding_summary: null
}
];
state.mqtt = {
publisher: {
mode: "broker",
connected: true,
broker_url: "mqtts://broker.bizone.id:8883"
},
subscriber: {
connected: true
},
last_messages: [
{
direction: "downlink",
topic: "soundbox/SN-QF100-0001/down",
message_type: "payment_success",
publish_status: "sent",
created_at: new Date(now - 75 * 1000).toISOString()
},
{
direction: "uplink",
topic: "devices/dev_qf100_dynamic_01/uplink/dynamic-qr/request",
message_type: "dynamic_qr_request",
publish_status: "recorded",
created_at: new Date(now - 7 * 60 * 1000).toISOString()
},
{
direction: "downlink",
topic: "devices/dev_counter_03/downlink/config/push",
message_type: "config_push",
publish_status: "sent",
created_at: new Date(now - 18 * 60 * 1000).toISOString()
}
]
};
state.observability = {
generated_at: new Date().toISOString(),
database: { status: "ok" },
notifications: { pending_count: 1, failed_count: 0 },
export_jobs: { worker: { enabled: true } }
};
renderAll();
}
async function refresh() {
const error = $("error-banner");
error.classList.add("hidden");
if (isPreviewMode && !api.getToken()) {
loadPreviewData();
return;
}
try {
api.requireToken();
const [devices, merchants, mqtt, observability] = await Promise.all([
api.listDevices(),
api.listMerchants(),
api.getMqttStatus({ limit: 20 }),
api.getObservabilitySummary()
]);
state.devices = Array.isArray(devices) ? devices : [];
state.mqtt = mqtt || {};
state.observability = observability || {};
state.merchants.clear();
(Array.isArray(merchants) ? merchants : []).forEach((merchant) => {
state.merchants.set(merchant.id, merchant.legal_name || merchant.brand_name || merchant.merchant_code || merchant.id);
});
renderAll();
} catch (err) {
if (String(err?.message || err).includes("ADMIN_AUTH_MISSING")) return;
error.textContent = err?.message || "Unable to load soundbox monitoring data.";
error.classList.remove("hidden");
}
}
async function sendDeviceCommand(commandName, buttonId, actionLabel) {
const select = $("command-device-select");
const button = $(buttonId);
const status = $("command-status");
const deviceId = select?.value || "";
const device = state.devices.find((item) => item.id === deviceId);
if (!deviceId || !button || !status) {
return;
}
button.disabled = true;
button.classList.add("opacity-60");
status.textContent = `Sending ${actionLabel.toLowerCase()} to ${device?.serial_number || device?.device_code || deviceId}...`;
status.className = "mt-2 min-h-5 text-xs font-semibold text-slate-500";
try {
const result = await api.createDeviceCommand(deviceId, {
command: commandName,
payload: { requested_from: "soundbox_ops" }
});
const topic = result?.result_payload?.topic || "-";
status.textContent = `${actionLabel} command ${result.status || "queued"} · ${topic}`;
status.className = "mt-2 min-h-5 text-xs font-semibold text-emerald-700";
await refresh();
} catch (error) {
status.textContent = error?.message || `Unable to send ${actionLabel.toLowerCase()} command.`;
status.className = "mt-2 min-h-5 text-xs font-semibold text-red-700";
} finally {
button.classList.remove("opacity-60");
button.disabled = !select.value;
}
}
function applyStatusFilter(value) {
$("status-filter").value = value;
renderTable();
}
$("refresh-button").addEventListener("click", refresh);
$("search-input").addEventListener("input", renderTable);
$("status-filter").addEventListener("change", renderTable);
$("kpi-total-card")?.addEventListener("click", () => applyStatusFilter(""));
$("kpi-online-card")?.addEventListener("click", () => applyStatusFilter("online"));
$("kpi-warning-card")?.addEventListener("click", () => applyStatusFilter("warning"));
$("kpi-offline-card")?.addEventListener("click", () => applyStatusFilter("offline"));
$("command-device-select")?.addEventListener("change", () => {
const hasDevice = Boolean($("command-device-select").value);
$("send-reboot-command").disabled = !hasDevice;
$("send-poweroff-command").disabled = !hasDevice;
$("command-status").textContent = "";
});
$("send-reboot-command")?.addEventListener("click", () => sendDeviceCommand("device.reboot", "send-reboot-command", "Reboot"));
$("send-poweroff-command")?.addEventListener("click", () => sendDeviceCommand("device.poweroff", "send-poweroff-command", "Power off"));
$("logout-button").addEventListener("click", () => {
api.clearToken();
window.location.href = "/ui/admin-login";
});
document.querySelectorAll("a[href^='#']").forEach((link) => {
link.addEventListener("click", (event) => {
const id = link.getAttribute("href").slice(1);
if (!id) {
return;
}
event.preventDefault();
history.replaceState(null, "", `#${id}`);
focusSection(id);
});
});
refresh();
if (window.location.hash) {
window.setTimeout(() => focusSection(window.location.hash.slice(1)), 250);
}
window.setInterval(refresh, 30000);
})();
</script>
</body>
</html>