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

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