Complete QF100 ops commands and detail UI
This commit is contained in:
@ -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);
|
||||
|
||||
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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>
|
||||
|
||||
@ -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";
|
||||
|
||||
Reference in New Issue
Block a user