Files
Qris-Soundbox/ui/config-commands/index.html
2026-06-07 03:12:18 +07:00

328 lines
18 KiB
HTML

<!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>