Complete QF100 ops commands and detail UI

This commit is contained in:
Wira Basalamah
2026-06-07 02:55:57 +07:00
parent 1550484d1d
commit e3d7e60ff3
8 changed files with 608 additions and 63 deletions

View File

@ -455,13 +455,14 @@ Rows
<img alt="Soundbox V2 Product" class="w-20 h-20 rounded-xl bg-slate-100" data-alt="A clean professional studio product shot of a minimalist electronic soundbox speaker device with a small LCD screen and premium matte plastic finish. The lighting is soft and corporate with subtle blue reflections on the surface consistent with a high-end fintech hardware brand. The background is a clean neutral white studio setting." src="https://lh3.googleusercontent.com/aida-public/AB6AXuC-CSPTCnxQuDTN1XM0atRPM9hIcVzf3zpbuxEUGTIlC-c1BivDqPa9osmBscvoiUcJeMBwUaXbZ6Ut5FuG2a91sVtZjzWRTgLck34kJJJy3N2E9O3uVtZw6InOpX9Gkph2OJxu_Z-PkR_t3F56EVZY3u8o2iZO3iH8hj9_ajrku7g1r_l54uobcRoN3dRH3k_at6GTuGbMtSSD4ew24sX8nePUsVvILKJauQLcMKD14J6mtAGm0x5PfViQQKdJzf_pYMqKswr3Yz4"/>
<div>
<h4 class="font-bold text-headline-md mb-1" id="device-detail-title">-</h4>
<p class="font-mono text-body-md text-slate-500" id="device-detail-serial">SN: -</p>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-slate-100 text-slate-600" id="device-detail-model">Device</span>
</div>
</div>
<div class="space-y-6" id="device-detail-content"></div>
</div>
<div class="p-6 border-t border-slate-200 grid grid-cols-2 gap-4">
<button class="w-full py-2.5 border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-colors">Reboot Device</button>
<button id="drawer-reboot-device" class="w-full py-2.5 border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-colors">Reboot Device</button>
<button class="w-full py-2.5 bg-danger/10 text-danger rounded-xl font-bold hover:bg-danger/20 transition-colors">Unbind Merchant</button>
</div>
</div>
@ -495,8 +496,10 @@ Rows
const detailDrawer = document.getElementById("device-detail-drawer");
const detailCloseButton = document.getElementById("device-detail-close");
const detailTitle = document.getElementById("device-detail-title");
const detailSerial = document.getElementById("device-detail-serial");
const detailModel = document.getElementById("device-detail-model");
const detailContent = document.getElementById("device-detail-content");
const drawerRebootButton = document.getElementById("drawer-reboot-device");
const registerModal = document.getElementById("device-register-modal");
const registerForm = document.getElementById("device-register-form");
const topbarRegisterOpenButton = document.getElementById("topbar-register-device-open");
@ -612,6 +615,9 @@ Rows
if (value === "applied") {
return { label: "Applied", className: "bg-success/10 text-success border-success/20", icon: "check_circle" };
}
if (value === "pulled_not_pushed") {
return { label: "Pulled by Device", className: "bg-success/10 text-success border-success/20", icon: "check_circle" };
}
if (value === "pending_ack") {
return { label: "Pending ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "pending" };
}
@ -834,6 +840,7 @@ Rows
let currentPage = 1;
let pageSize = Number(pageSizeSelect?.value || 10);
let currentSearchQuery = "";
let activeDrawerDevice = null;
let merchants = [];
let outlets = [];
let terminals = [];
@ -855,6 +862,7 @@ Rows
tableBody.innerHTML = items
.map((device) => {
const id = device.device_code || device.id || "";
const serialNumber = escapeHtml(device.serial_number || "-");
const model = device.model || "Unknown";
const binding = device.binding_summary || {};
const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned";
@ -866,7 +874,10 @@ Rows
return `
<tr class="hover:bg-slate-50 transition-colors group">
<td class="px-6 py-row-height">
<span class="font-mono text-primary font-bold">${id || "-"}</span>
<div class="space-y-1">
<span class="block font-mono text-primary font-bold">${id || "-"}</span>
<span class="block font-mono text-[12px] text-slate-500">SN: ${serialNumber}</span>
</div>
</td>
<td class="px-6 py-row-height">${model}</td>
<td class="px-6 py-row-height">
@ -1140,6 +1151,7 @@ Rows
if (!detailDrawer || !detailOverlay || !detailTitle || !detailModel || !detailContent) {
return;
}
activeDrawerDevice = device;
const binding = device.binding_summary || {};
const connection = connectionMeta(device.communication_mode);
@ -1152,7 +1164,11 @@ Rows
: "No active warning";
const id = device.device_code || device.id || "-";
const serialNumber = escapeHtml(device.serial_number || "-");
detailTitle.textContent = id;
if (detailSerial) {
detailSerial.textContent = `SN: ${serialNumber}`;
}
detailModel.textContent = device.model || "Unknown";
detailModel.className = `inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${status.className}`;
@ -1160,6 +1176,10 @@ Rows
<section>
<h5 class="text-label-md font-label-md text-slate-500 uppercase mb-3">Device Detail</h5>
<div class="bg-slate-50 p-4 rounded-xl border border-slate-100">
<div class="flex justify-between gap-4 mb-2">
<span class="text-on-surface-variant">Serial Number</span>
<span class="font-mono font-bold text-right">${serialNumber}</span>
</div>
<div class="flex justify-between mb-2">
<span class="text-on-surface-variant">Model</span>
<span class="font-bold">${device.model || "Unknown"}</span>
@ -1233,6 +1253,33 @@ Rows
loadDrawerConfig(device.id);
};
const sendDrawerRebootCommand = async () => {
if (!activeDrawerDevice || !drawerRebootButton) {
return;
}
const originalText = drawerRebootButton.textContent || "Reboot Device";
drawerRebootButton.disabled = true;
drawerRebootButton.textContent = "Sending...";
try {
const result = await api.createDeviceCommand(activeDrawerDevice.id, {
command: "device.reboot",
payload: { requested_from: "device_registry_drawer" }
});
drawerRebootButton.textContent = result.status === "delivered" ? "Reboot Sent" : "Reboot Queued";
window.setTimeout(() => {
drawerRebootButton.textContent = originalText;
drawerRebootButton.disabled = false;
}, 1800);
} catch (error) {
drawerRebootButton.textContent = "Reboot Failed";
window.setTimeout(() => {
drawerRebootButton.textContent = originalText;
drawerRebootButton.disabled = false;
}, 2200);
}
};
const loadDrawerConfig = async (deviceId) => {
const box = document.getElementById("device-config-status-box");
if (!box || !deviceId) {
@ -1244,6 +1291,7 @@ Rows
const meta = configStatusMeta(configStatus.drift_status);
const latestPush = configStatus.latest_push;
const latestAck = configStatus.latest_ack;
const latestPull = configStatus.latest_config_pull;
const canRetry = configStatus.retry_recommended;
box.innerHTML = `
<div class="flex items-center justify-between gap-3 mb-3">
@ -1253,7 +1301,11 @@ Rows
</span>
<span class="font-mono text-slate-500">v${configStatus.desired_config_version || "-"}</span>
</div>
<div class="grid grid-cols-2 gap-3 text-sm mb-4">
<div class="grid grid-cols-3 gap-3 text-sm mb-4">
<div>
<p class="text-slate-500">Latest Pull</p>
<p class="font-bold">${latestPull ? formatLastSeen(latestPull.received_at || latestPull.timestamp) : "-"}</p>
</div>
<div>
<p class="text-slate-500">Latest Push</p>
<p class="font-bold">${latestPush ? formatLastSeen(latestPush.created_at) : "-"}</p>
@ -1472,6 +1524,7 @@ Rows
if (!detailDrawer || !detailOverlay) {
return;
}
activeDrawerDevice = null;
detailDrawer.classList.add("translate-x-full");
detailOverlay.classList.remove("opacity-100");
detailOverlay.classList.add("opacity-0", "pointer-events-none");
@ -1578,6 +1631,7 @@ Rows
});
detailOverlay?.addEventListener("click", closeDrawer);
detailCloseButton?.addEventListener("click", closeDrawer);
drawerRebootButton?.addEventListener("click", sendDrawerRebootCommand);
topbarRegisterOpenButton?.addEventListener("click", openRegisterModal);
refreshButton?.addEventListener("click", refresh);
registerCloseButton?.addEventListener("click", closeRegisterModal);

View File

@ -211,6 +211,10 @@
<span id="device-model">Soundbox V2 Pro</span>
</p>
<p class="text-body-md text-on-surface-variant flex items-center gap-1.5">
<span class="material-symbols-outlined text-sm">tag</span>
<span id="device-serial-number">SN: -</span>
</p>
<p class="text-body-md text-on-surface-variant flex items-center gap-1.5">
<span class="material-symbols-outlined text-sm">schedule</span>
<span id="device-last-seen">Last seen 2 mins ago</span>
</p>
@ -235,7 +239,7 @@
<!-- Tab Navigation -->
<div class="border-b border-slate-200 mb-8 flex gap-8">
<button class="pb-4 text-body-md font-bold text-primary border-b-2 border-primary" data-scroll-target="overview-section">Overview</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="heartbeat-section">Heartbeat</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="payload-stream">Heartbeat</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="configuration-section">Configuration</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="binding-section">Binding History</button>
<button id="dynamic-qr-tab" class="hidden pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="dynamic-qr-panel">Dynamic QR</button>
@ -376,7 +380,7 @@ Loading
</button>
</div>
</div>
<div id="heartbeat-section" class="p-4 flex-1 overflow-y-auto code-font text-[13px] text-green-400 space-y-1 custom-scroll">
<div id="payload-stream" data-section="heartbeat-section" class="p-4 flex-1 overflow-y-auto code-font text-[13px] text-green-400 space-y-1 custom-scroll">
<p class="text-slate-500">[14:02:11] INITIALIZING WEBSOCKET CONNECTION...</p>
<p class="text-slate-500">[14:02:12] CONNECTED TO SND-10293_GATEWAY_V4</p>
<p class="text-success">[14:02:15] RECV: {"event": "heartbeat", "status": "online", "v_batt": 4.12, "rssi": -78, "ts": 1715421255}</p>
@ -492,7 +496,7 @@ Rotate Credential
<div class="rounded-[28px] border-[10px] border-slate-900 bg-slate-950 p-4 shadow-xl">
<div class="rounded-2xl bg-white p-4 text-center">
<p class="text-[11px] font-bold uppercase tracking-wider text-slate-500">QRIS Payment</p>
<div id="qr-preview-grid" class="mx-auto my-4 grid h-40 w-40 grid-cols-9 grid-rows-9 gap-1 rounded-lg bg-white p-2"></div>
<div id="qr-preview-grid" class="mx-auto my-4 flex h-40 w-40 items-center justify-center rounded-lg bg-white p-2"></div>
<p id="qr-preview-amount" class="text-xl font-extrabold text-slate-950">Rp 1.000</p>
<p id="qr-preview-device" class="mt-1 font-mono text-[11px] text-slate-500">-</p>
</div>
@ -582,7 +586,7 @@ Copy Command
const qs = new URLSearchParams(window.location.search);
const deviceId = qs.get("device_id") || qs.get("deviceId") || qs.get("id") || "";
let activeDeviceId = deviceId;
const stream = document.getElementById("payload-stream");
const stream = document.getElementById("payload-stream") || document.getElementById("heartbeat-section");
const clearBtn = document.getElementById("clearConsole");
const exportBtn = document.getElementById("export-device-logs");
const refreshBtn = document.getElementById("refresh-device-state");
@ -628,6 +632,7 @@ Copy Command
let showingAllEvents = false;
let confirmResolver = null;
let latestCredentialCommand = "";
let liveRefreshTimer = null;
const els = {
breadcrumbCode: document.getElementById("device-breadcrumb-code"),
@ -635,6 +640,7 @@ Copy Command
statusBadge: document.getElementById("device-status-badge"),
statusDot: document.getElementById("device-status-dot"),
model: document.getElementById("device-model"),
serialNumber: document.getElementById("device-serial-number"),
lastSeen: document.getElementById("device-last-seen"),
location: document.getElementById("device-location"),
signalStrength: document.getElementById("device-signal-strength"),
@ -739,6 +745,46 @@ Copy Command
return new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short" }).format(ms);
};
const formatClock = (value) => {
const ms = normalizeTimestamp(value) || Date.now();
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(ms);
};
const escapeHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const formatEventTitle = (item) => {
const state = String(item?.state || item?.event || item?.type || item?.status || "heartbeat")
.replace(/_/g, " ")
.trim();
return state ? state.replace(/\b\w/g, (char) => char.toUpperCase()) : "Heartbeat";
};
const compactPayload = (item) => {
const payload = item?.payload && typeof item.payload === "object" ? item.payload : item;
const picked = {
id: payload?.id,
serial: payload?.["dev-sn"] || payload?.serial_number || currentDevice?.serial_number,
state: payload?.state || item?.state,
firmware: payload?.["fw-version"] || item?.firmware_version,
signal: item?.network_strength ?? payload?.network_strength ?? payload?.rssi ?? payload?.["wifi-ap"]?.rssi,
battery: item?.battery_level ?? payload?.battery_level ?? payload?.["battery-level"],
received_at: item?.received_at,
timestamp: item?.timestamp || payload?.time
};
return Object.fromEntries(Object.entries(picked).filter(([, value]) => value !== undefined && value !== null && value !== ""));
};
const extractHeartbeatMetrics = (heartbeat) => {
if (!heartbeat || typeof heartbeat !== "object") {
return {};
@ -812,17 +858,21 @@ Copy Command
item.timestamp || item.ts || item.created_at || item.updated_at,
"unknown"
);
const title = item.event || item.type || item.status || "Heartbeat";
const details = item.message || item.description || JSON.stringify(item.payload || item);
const marker = iconClass(item.status || item.event);
return `<div class="relative pl-8">
const title = formatEventTitle(item);
const summary = compactPayload(item);
const marker = iconClass(item.status || item.event || item.state);
return `<div class="relative pl-8 min-w-0">
<div class="absolute left-0 top-1 w-6 h-6 rounded-full ${marker.color} border-4 border-white shadow-sm flex items-center justify-center">
<span class="material-symbols-outlined text-[12px] text-white" style="font-variation-settings: 'FILL' 1;">${marker.icon}</span>
</div>
<p class="text-label-md font-bold">${title}</p>
<p class="text-[12px] text-on-surface-variant">Signal: ${metric.signal ?? "N/A"}, Battery: ${metric.battery ?? "N/A"}</p>
<p class="text-[12px] text-on-surface-variant">${details}</p>
<p class="text-[10px] text-slate-400 mt-1">${when}</p>
<div class="min-w-0 rounded-lg border border-slate-100 bg-slate-50/60 p-3">
<div class="flex items-start justify-between gap-3">
<p class="text-label-md font-bold text-slate-900">${escapeHtml(title)}</p>
<p class="shrink-0 text-[10px] text-slate-400">${escapeHtml(when)}</p>
</div>
<p class="mt-1 text-[12px] text-on-surface-variant">Signal: ${escapeHtml(metric.signal ?? "N/A")}, Battery: ${escapeHtml(metric.battery ?? "N/A")}</p>
<pre class="mt-2 max-h-24 overflow-auto whitespace-pre-wrap break-words rounded-md bg-white px-2 py-1.5 text-[11px] leading-5 text-slate-600">${escapeHtml(JSON.stringify(summary, null, 2))}</pre>
</div>
</div>`;
})
.join("");
@ -840,11 +890,11 @@ Copy Command
}
rows.slice(0, 20).forEach((item) => {
const when = formatDateTime(item.timestamp || item.ts || item.created_at || item.updated_at, "now");
const p = document.createElement("p");
p.className = "text-green-400";
p.textContent = `[${when}] RECV: ${JSON.stringify(item)}`;
stream.appendChild(p);
const when = formatClock(item.timestamp || item.ts || item.created_at || item.updated_at || item.received_at);
const line = document.createElement("pre");
line.className = "whitespace-pre-wrap break-words text-green-400 leading-5";
line.textContent = `[${when}] RECV heartbeat\n${JSON.stringify(compactPayload(item), null, 2)}`;
stream.appendChild(line);
});
stream.scrollTop = stream.scrollHeight;
};
@ -889,7 +939,7 @@ Copy Command
const statusPillClass = (status) => {
const normalized = String(status || "").toLowerCase();
if (normalized === "online" || normalized === "applied") {
if (normalized === "online" || normalized === "applied" || normalized === "pulled_not_pushed") {
return "bg-success/10 text-success border-success/20";
}
if (normalized === "degraded" || normalized === "stale" || normalized === "pending_ack" || normalized === "stale_ack") {
@ -932,18 +982,21 @@ Copy Command
const version = status.desired_config_version || status.config?.config_version || "-";
setText(els.configVersion, `Config v${version}`);
if (els.configStatus) {
const label = String(drift).replace("_", " ").toUpperCase();
const icon = drift === "applied" ? "check_circle" : drift === "failed_ack" ? "error" : "pending";
const label = drift === "pulled_not_pushed" ? "PULLED BY DEVICE" : String(drift).replace(/_/g, " ").toUpperCase();
const icon = drift === "applied" || drift === "pulled_not_pushed" ? "check_circle" : drift === "failed_ack" ? "error" : "pending";
els.configStatus.className = `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${statusPillClass(drift)}`;
els.configStatus.innerHTML = `<span class="material-symbols-outlined text-[16px]">${icon}</span>${label}`;
}
const ack = status.latest_ack ? `${status.latest_ack.status} at ${formatDateTime(status.latest_ack.acked_at, "-")}` : "No ACK";
const push = status.latest_push ? formatDateTime(status.latest_push.created_at, "-") : "No push";
setText(els.configDetail, `Push: ${push} · ACK: ${ack}`);
const pull = status.latest_config_pull ? formatDateTime(status.latest_config_pull.received_at || status.latest_config_pull.timestamp, "-") : "No pull";
setText(els.configDetail, `Pull: ${pull} · Push: ${push} · ACK: ${ack}`);
if (els.configRetry) {
els.configRetry.disabled = status.retry_recommended === false;
els.configRetry.textContent = status.retry_recommended === false ? "Applied" : "Retry Push";
els.configRetry.textContent = status.retry_recommended === false
? (drift === "pulled_not_pushed" ? "Pulled" : "Applied")
: "Retry Push";
}
};
@ -1049,9 +1102,10 @@ Copy Command
if (!stream) {
return;
}
const p = document.createElement("p");
const p = document.createElement("pre");
p.className = className;
p.textContent = `[${formatDateTime(Date.now())}] ${message}`;
p.classList.add("whitespace-pre-wrap", "break-words", "leading-5");
p.textContent = `[${formatClock(Date.now())}] ${message}`;
stream.appendChild(p);
stream.scrollTop = stream.scrollHeight;
};
@ -1094,21 +1148,23 @@ Copy Command
};
const buildQrPreviewPayload = () => ({
device_id: activeDeviceId || "-",
device_code: currentDevice?.device_code || currentDevice?.id || "-",
amount: 1000,
currency: "IDR",
qr_mode: els.dynamicQrMode?.textContent || "dynamic",
expires_in_seconds: 60,
preview: true
header: {
category: 4
},
data: {
"qr-url": `https://sms.bizone.id/pay/test/${encodeURIComponent(currentDevice?.serial_number || activeDeviceId || "soundbox")}`,
amount: 1000,
"expire-seconds": 60
}
});
const renderQrPreviewGrid = () => {
const renderFallbackQrPreviewGrid = (qrUrl) => {
if (!qrPreviewGrid) {
return;
}
const seed = String(activeDeviceId || currentDevice?.device_code || "soundbox");
const seed = String(qrUrl || activeDeviceId || currentDevice?.device_code || "soundbox");
qrPreviewGrid.innerHTML = "";
qrPreviewGrid.className = "mx-auto my-4 grid h-40 w-40 grid-cols-9 grid-rows-9 gap-1 rounded-lg bg-white p-2";
for (let index = 0; index < 81; index += 1) {
const char = seed.charCodeAt(index % seed.length) || 37;
const dark = index < 9 || index % 9 === 0 || ((char + index * 7) % 5 < 2);
@ -1118,17 +1174,31 @@ Copy Command
}
};
const renderQrPreviewCode = (qrUrl) => {
if (!qrPreviewGrid) {
return;
}
qrPreviewGrid.innerHTML = "";
qrPreviewGrid.className = "mx-auto my-4 flex h-40 w-40 items-center justify-center rounded-lg bg-white p-2";
const image = document.createElement("img");
image.alt = "QR code preview";
image.className = "h-full w-full object-contain";
image.src = `https://api.qrserver.com/v1/create-qr-code/?size=192x192&margin=8&data=${encodeURIComponent(qrUrl)}`;
image.addEventListener("error", () => renderFallbackQrPreviewGrid(qrUrl), { once: true });
qrPreviewGrid.appendChild(image);
};
const openQrPreview = () => {
const payload = buildQrPreviewPayload();
renderQrPreviewGrid();
renderQrPreviewCode(payload.data["qr-url"]);
setText(qrPreviewAmount, new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0
}).format(payload.amount));
setText(qrPreviewDevice, payload.device_code);
}).format(payload.data.amount));
setText(qrPreviewDevice, currentDevice?.device_code || currentDevice?.serial_number || activeDeviceId || "-");
setText(qrPreviewCommandPath, els.dynamicQrCommandPath?.textContent || "MQTT");
setText(qrPreviewMode, payload.qr_mode);
setText(qrPreviewMode, els.dynamicQrMode?.textContent || "dynamic");
setText(qrPreviewPayload, JSON.stringify(payload, null, 2));
qrPreviewModal?.classList.remove("hidden");
qrPreviewModal?.classList.add("flex");
@ -1219,7 +1289,7 @@ Copy Command
setText(els.bindingSince, formatDateTime(bindingDate, "-"));
};
const loadDevice = async () => {
const loadDevice = async ({ preserveEventView = false } = {}) => {
try {
api.requireToken();
let selectedDeviceId = deviceId;
@ -1253,12 +1323,15 @@ Copy Command
: [];
currentDevice = device;
currentHeartbeats = heartbeats;
showingAllEvents = false;
if (!preserveEventView) {
showingAllEvents = false;
}
const modelCode = device.device_code || device.code || device.serial_number || device.id || "Unknown Device";
setText(els.breadcrumbCode, modelCode);
setText(els.title, modelCode);
setText(els.model, device.model || device.device_model || "Unknown model");
setText(els.serialNumber, `SN: ${device.serial_number || "-"}`);
setText(els.location, device.location || device.last_known_city || "Unknown");
const latest = Array.isArray(heartbeats) && heartbeats.length ? heartbeats[0] : null;
@ -1294,7 +1367,18 @@ Copy Command
}
};
refreshBtn?.addEventListener("click", loadDevice);
const startLiveRefresh = () => {
if (liveRefreshTimer) {
window.clearInterval(liveRefreshTimer);
}
liveRefreshTimer = window.setInterval(() => {
if (document.visibilityState === "visible") {
loadDevice({ preserveEventView: true });
}
}, 10000);
};
refreshBtn?.addEventListener("click", () => loadDevice());
rotateCredentialButtons.forEach((button) => button.addEventListener("click", rotateCredential));
credentialModalClose?.addEventListener("click", closeCredentialModal);
credentialModalDone?.addEventListener("click", closeCredentialModal);
@ -1325,7 +1409,7 @@ Copy Command
}
});
els.dynamicQrSendTest?.addEventListener("click", () => {
sendDeviceCommand("dynamic_qr.test", {
sendDeviceCommand("dynamic_qr.display", {
...buildQrPreviewPayload(),
source: "device_detail"
}, els.dynamicQrSendTest);
@ -1409,7 +1493,7 @@ Copy Command
}
});
qrPreviewSendTest?.addEventListener("click", () => {
sendDeviceCommand("dynamic_qr.test", {
sendDeviceCommand("dynamic_qr.display", {
...buildQrPreviewPayload(),
source: "qr_preview_modal"
}, qrPreviewSendTest);
@ -1480,6 +1564,7 @@ Copy Command
}
loadDevice();
startLiveRefresh();
})();
</script>
</body></html>

View File

@ -181,6 +181,26 @@
<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>
<p id="command-status" class="mt-2 min-h-5 text-xs font-semibold text-slate-500"></p>
</div>
</div>
</section>
@ -346,6 +366,27 @@
$("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");
if (!select || !rebootButton) {
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.device_code || device.serial_number || device.id} · ${device.serial_number || device.model || "soundbox"}`;
select.appendChild(option);
});
if (current && state.devices.some((device) => device.id === current)) {
select.value = current;
}
rebootButton.disabled = !select.value;
}
function renderMqtt() {
const list = $("mqtt-list");
const messages = Array.isArray(state.mqtt?.last_messages) ? state.mqtt.last_messages : [];
@ -373,6 +414,7 @@
renderKpis();
renderTable();
renderOps();
renderCommandDevices();
renderMqtt();
}
@ -517,9 +559,46 @@
}
}
async function sendRebootCommand() {
const select = $("command-device-select");
const button = $("send-reboot-command");
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 reboot 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",
payload: { requested_from: "soundbox_ops" }
});
const topic = result?.result_payload?.topic || "-";
status.textContent = `Reboot 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.className = "mt-2 min-h-5 text-xs font-semibold text-red-700";
} finally {
button.classList.remove("opacity-60");
button.disabled = !select.value;
}
}
$("refresh-button").addEventListener("click", refresh);
$("search-input").addEventListener("input", renderTable);
$("status-filter").addEventListener("change", renderTable);
$("command-device-select")?.addEventListener("change", () => {
$("send-reboot-command").disabled = !$("command-device-select").value;
$("command-status").textContent = "";
});
$("send-reboot-command")?.addEventListener("click", sendRebootCommand);
$("logout-button").addEventListener("click", () => {
api.clearToken();
window.location.href = "/ui/admin-login";