|
|
|
|
@ -3,21 +3,409 @@
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
|
|
|
<title>soundbox-ops</title>
|
|
|
|
|
<style>body{font-family:Inter,Arial;background:#f8fafc;color:#0f172a;padding:32px}a{color:#2563eb}</style>
|
|
|
|
|
<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; }
|
|
|
|
|
.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>
|
|
|
|
|
<h1>soundbox-ops</h1>
|
|
|
|
|
<p>Folder desain ini hanya menyediakan <strong>DESIGN.md</strong> (tanpa code.html).</p>
|
|
|
|
|
<p><a href="/ui">Kembali ke katalog</a></p>
|
|
|
|
|
<!-- ui-nav -->
|
|
|
|
|
<div id="__sb_nav" style="position:fixed;left:16px;bottom:16px;z-index:9999;background:#fff;border:1px solid #e2e8f0;padding:8px 10px;border-radius:8px;box-shadow:0 6px 24px rgba(15,23,42,0.12);font-family:Inter,Arial,sans-serif;font-size:12px;line-height:1.4">
|
|
|
|
|
<a href="/ui" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">UI Catalog</a>
|
|
|
|
|
<a href="/ui/hub" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Hub</a>
|
|
|
|
|
<a href="/ui/admin-login" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Admin Login</a>
|
|
|
|
|
<a href="/ui/merchant-login" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Merchant Login</a>
|
|
|
|
|
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
|
|
|
|
</div>
|
|
|
|
|
'
|
|
|
|
|
<body class="min-h-screen bg-slate-50 text-slate-950">
|
|
|
|
|
<aside class="fixed inset-y-0 left-0 z-40 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-xl font-extrabold text-blue-700">Soundbox Ops</h1>
|
|
|
|
|
<p class="mt-1 text-xs font-semibold uppercase text-slate-500">Monitoring Console</p>
|
|
|
|
|
</div>
|
|
|
|
|
<nav class="mt-8 flex flex-1 flex-col gap-1">
|
|
|
|
|
<a class="flex items-center gap-3 rounded-lg bg-blue-50 px-3 py-2 font-bold text-blue-700" href="/ui/soundbox-ops">
|
|
|
|
|
<span class="material-symbols-outlined">monitor_heart</span>
|
|
|
|
|
Soundbox Monitoring
|
|
|
|
|
</a>
|
|
|
|
|
<a class="flex items-center gap-3 rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-100" href="/ui/device-registry-monitoring">
|
|
|
|
|
<span class="material-symbols-outlined">speaker_group</span>
|
|
|
|
|
Device Registry
|
|
|
|
|
</a>
|
|
|
|
|
<a class="flex items-center gap-3 rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-100" href="/ui/transaction-history-monitoring">
|
|
|
|
|
<span class="material-symbols-outlined">receipt_long</span>
|
|
|
|
|
Transactions
|
|
|
|
|
</a>
|
|
|
|
|
<a class="flex items-center gap-3 rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-100" href="/ui/admin-dashboard-overview">
|
|
|
|
|
<span class="material-symbols-outlined">dashboard</span>
|
|
|
|
|
Admin Overview
|
|
|
|
|
</a>
|
|
|
|
|
</nav>
|
|
|
|
|
<div class="border-t border-slate-200 pt-4">
|
|
|
|
|
<button id="logout-button" class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-slate-600 hover:bg-slate-100">
|
|
|
|
|
<span class="material-symbols-outlined">logout</span>
|
|
|
|
|
Logout
|
|
|
|
|
</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 flex-wrap items-center gap-3">
|
|
|
|
|
<div class="relative min-w-64 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-600 focus:ring-blue-600" placeholder="Search code, serial, model, merchant" type="search" />
|
|
|
|
|
</div>
|
|
|
|
|
<select id="status-filter" class="rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-600 focus:ring-blue-600">
|
|
|
|
|
<option value="">All status</option>
|
|
|
|
|
<option value="online">Online</option>
|
|
|
|
|
<option value="degraded">Degraded</option>
|
|
|
|
|
<option value="stale">Stale</option>
|
|
|
|
|
<option value="offline">Offline</option>
|
|
|
|
|
</select>
|
|
|
|
|
<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-2 xl:grid-cols-5">
|
|
|
|
|
<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">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 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">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 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">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 class="mt-1 text-sm text-slate-500">needs operator attention</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">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 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 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 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">Soundbox</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>
|
|
|
|
|
<th class="px-5 py-3 text-right">Action</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="device-table" class="divide-y divide-slate-100">
|
|
|
|
|
<tr><td colspan="6" class="px-5 py-8 text-center text-slate-500">Loading devices...</td></tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</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">Operations Health</h3>
|
|
|
|
|
<p id="ops-generated" class="mt-1 text-sm text-slate-500">Waiting for summary</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>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<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">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 $ = (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 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 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 matchesStatus = !status || normalize(device.derived_status) === status;
|
|
|
|
|
return matchesQuery && matchesStatus;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderKpis() {
|
|
|
|
|
const total = state.devices.length;
|
|
|
|
|
const online = state.devices.filter((item) => normalize(item.derived_status) === "online").length;
|
|
|
|
|
const warning = state.devices.filter((item) => ["stale", "degraded"].includes(normalize(item.derived_status))).length;
|
|
|
|
|
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`;
|
|
|
|
|
|
|
|
|
|
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="6" 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 code = device.device_code || device.id || "-";
|
|
|
|
|
const detailUrl = `/ui/device-technical-detail?device_id=${encodeURIComponent(device.id)}`;
|
|
|
|
|
return `
|
|
|
|
|
<tr class="hover:bg-slate-50">
|
|
|
|
|
<td class="px-5 py-4">
|
|
|
|
|
<div class="font-bold text-slate-950">${code}</div>
|
|
|
|
|
<div class="mono mt-1 text-xs text-slate-500">${device.serial_number || device.id || "-"}</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-5 py-4">
|
|
|
|
|
<div class="font-semibold">${merchantName(device)}</div>
|
|
|
|
|
<div class="mt-1 text-xs text-slate-500">${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]">${mode.icon}</span>${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>${status.label}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-1 text-xs text-slate-500">Health ${healthLabel(device)}</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="px-5 py-4 text-right text-slate-600">${lastSeen(device.latest_heartbeat)}</td>
|
|
|
|
|
<td class="px-5 py-4 text-right">
|
|
|
|
|
<a class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-slate-700 hover:bg-slate-50" href="${detailUrl}">
|
|
|
|
|
Detail <span class="material-symbols-outlined text-[16px]">open_in_new</span>
|
|
|
|
|
</a>
|
|
|
|
|
</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 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}">${message.direction || "trace"}</span>
|
|
|
|
|
<span class="text-xs text-slate-500">${lastSeen(message.created_at)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mono mt-2 break-all text-xs font-semibold text-slate-700">${message.topic || "-"}</div>
|
|
|
|
|
<div class="mt-1 text-xs text-slate-500">${message.message_type || "message"} · ${message.publish_status || "recorded"}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}).join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderAll() {
|
|
|
|
|
renderKpis();
|
|
|
|
|
renderTable();
|
|
|
|
|
renderOps();
|
|
|
|
|
renderMqtt();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refresh() {
|
|
|
|
|
const error = $("error-banner");
|
|
|
|
|
error.classList.add("hidden");
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("refresh-button").addEventListener("click", refresh);
|
|
|
|
|
$("search-input").addEventListener("input", renderTable);
|
|
|
|
|
$("status-filter").addEventListener("change", renderTable);
|
|
|
|
|
$("logout-button").addEventListener("click", () => {
|
|
|
|
|
api.clearToken();
|
|
|
|
|
window.location.href = "/ui/admin-login";
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
refresh();
|
|
|
|
|
window.setInterval(refresh, 30000);
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
|