Improve soundbox ops dashboard and registry editing
This commit is contained in:
@ -8,7 +8,7 @@
|
||||
<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; }
|
||||
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; }
|
||||
@ -60,19 +60,20 @@
|
||||
</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">
|
||||
<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="rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-600 focus:ring-blue-600">
|
||||
<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 items-center gap-2 rounded-lg bg-blue-700 px-4 py-2 text-sm font-bold text-white hover:bg-blue-800">
|
||||
<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>
|
||||
@ -85,7 +86,7 @@
|
||||
<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">
|
||||
<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>
|
||||
@ -93,7 +94,7 @@
|
||||
<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">
|
||||
<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>
|
||||
@ -101,21 +102,21 @@
|
||||
<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">
|
||||
<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 class="mt-1 text-sm text-slate-500">needs operator attention</p>
|
||||
<p id="kpi-warning-detail" class="mt-1 text-sm text-slate-500">0 stale · 0 degraded</p>
|
||||
</article>
|
||||
<article class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<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 class="mt-1 text-sm text-slate-500">no recent heartbeat</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">
|
||||
@ -123,7 +124,7 @@
|
||||
<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>
|
||||
<p id="kpi-mqtt-detail" class="mt-1 break-all text-sm text-slate-500">broker state</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@ -148,11 +149,10 @@
|
||||
<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>
|
||||
<tr><td colspan="5" class="px-5 py-8 text-center text-slate-500">Loading devices...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -199,6 +199,10 @@
|
||||
<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>
|
||||
@ -230,6 +234,21 @@
|
||||
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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[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";
|
||||
@ -274,6 +293,23 @@
|
||||
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";
|
||||
@ -292,7 +328,9 @@
|
||||
merchantName(device)
|
||||
].map(normalize).join(" ");
|
||||
const matchesQuery = !query || text.includes(query);
|
||||
const matchesStatus = !status || normalize(device.derived_status) === status;
|
||||
const deviceStatus = normalize(device.derived_status);
|
||||
const matchesStatus = !status ||
|
||||
(status === "warning" ? ["stale", "degraded"].includes(deviceStatus) : deviceStatus === status);
|
||||
return matchesQuery && matchesStatus;
|
||||
});
|
||||
}
|
||||
@ -300,13 +338,17 @@
|
||||
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 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 || {};
|
||||
@ -322,42 +364,49 @@
|
||||
$("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>';
|
||||
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 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">
|
||||
<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>
|
||||
<a class="font-bold text-slate-950 hover:text-blue-700" href="${detailUrl}">${esc(code)}</a>
|
||||
<div class="mono mt-1 text-xs text-slate-500">${esc(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>
|
||||
<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]">${mode.icon}</span>${mode.label}
|
||||
<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>${status.label}
|
||||
<span class="h-1.5 w-1.5 rounded-full ${status.dot}"></span>${esc(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(deviceLastSeen(device))}</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>
|
||||
<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("");
|
||||
@ -379,7 +428,8 @@
|
||||
function renderCommandDevices() {
|
||||
const select = $("command-device-select");
|
||||
const rebootButton = $("send-reboot-command");
|
||||
if (!select || !rebootButton) {
|
||||
const poweroffButton = $("send-poweroff-command");
|
||||
if (!select || !rebootButton || !poweroffButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -395,6 +445,7 @@
|
||||
select.value = current;
|
||||
}
|
||||
rebootButton.disabled = !select.value;
|
||||
poweroffButton.disabled = !select.value;
|
||||
}
|
||||
|
||||
function renderMqtt() {
|
||||
@ -410,11 +461,11 @@
|
||||
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>
|
||||
<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">${message.topic || "-"}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">${message.message_type || "message"} · ${message.publish_status || "recorded"}</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("");
|
||||
@ -456,8 +507,12 @@
|
||||
model: "QF100 Static",
|
||||
communication_mode: "mqtt",
|
||||
derived_status: "online",
|
||||
latest_heartbeat: new Date(now - 90 * 1000).toISOString(),
|
||||
health_summary: { score: 98 },
|
||||
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" }
|
||||
},
|
||||
{
|
||||
@ -468,8 +523,12 @@
|
||||
model: "QF100 Dynamic",
|
||||
communication_mode: "mqtt",
|
||||
derived_status: "stale",
|
||||
latest_heartbeat: new Date(now - 42 * 60 * 1000).toISOString(),
|
||||
health_summary: { score: 64 },
|
||||
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" }
|
||||
},
|
||||
{
|
||||
@ -480,8 +539,12 @@
|
||||
model: "Soundbox V2",
|
||||
communication_mode: "api",
|
||||
derived_status: "degraded",
|
||||
latest_heartbeat: new Date(now - 12 * 60 * 1000).toISOString(),
|
||||
health_summary: { score: 72 },
|
||||
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" }
|
||||
},
|
||||
{
|
||||
@ -493,7 +556,7 @@
|
||||
communication_mode: "static",
|
||||
derived_status: "offline",
|
||||
latest_heartbeat: null,
|
||||
health_summary: { score: 0 },
|
||||
health_summary: { score: 0, reasons: ["no_heartbeat"], age_seconds: null },
|
||||
binding_summary: null
|
||||
}
|
||||
];
|
||||
@ -569,9 +632,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRebootCommand() {
|
||||
async function sendDeviceCommand(commandName, buttonId, actionLabel) {
|
||||
const select = $("command-device-select");
|
||||
const button = $("send-reboot-command");
|
||||
const button = $(buttonId);
|
||||
const status = $("command-status");
|
||||
const deviceId = select?.value || "";
|
||||
const device = state.devices.find((item) => item.id === deviceId);
|
||||
@ -581,19 +644,19 @@
|
||||
|
||||
button.disabled = true;
|
||||
button.classList.add("opacity-60");
|
||||
status.textContent = `Sending reboot to ${device?.device_code || device?.serial_number || deviceId}...`;
|
||||
status.textContent = `Sending ${actionLabel.toLowerCase()} to ${device?.device_code || device?.serial_number || deviceId}...`;
|
||||
status.className = "mt-2 min-h-5 text-xs font-semibold text-slate-500";
|
||||
try {
|
||||
const result = await api.createDeviceCommand(deviceId, {
|
||||
command: "device.reboot",
|
||||
command: commandName,
|
||||
payload: { requested_from: "soundbox_ops" }
|
||||
});
|
||||
const topic = result?.result_payload?.topic || "-";
|
||||
status.textContent = `Reboot command ${result.status || "queued"} · ${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 reboot command.";
|
||||
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");
|
||||
@ -601,14 +664,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
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", () => {
|
||||
$("send-reboot-command").disabled = !$("command-device-select").value;
|
||||
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", sendRebootCommand);
|
||||
$("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";
|
||||
|
||||
Reference in New Issue
Block a user