Continue phase 2 device ops and dynamic QR lifecycle

This commit is contained in:
2026-05-26 21:25:07 +07:00
parent 5624b92872
commit e0b8f9af9a
22 changed files with 1050 additions and 92 deletions

View File

@ -422,7 +422,19 @@
return `${days} day${days === 1 ? "" : "s"} ago`;
};
const healthMeta = (value) => {
const healthMeta = (value, summary) => {
if (summary && typeof summary.score === "number") {
if (summary.score >= 90) {
return { value: `${summary.score}%`, className: "text-success", icon: "favorite" };
}
if (summary.score >= 65) {
return { value: `${summary.score}%`, className: "text-success", icon: "favorite" };
}
if (summary.score >= 35) {
return { value: `${summary.score}%`, className: "text-warning", icon: "heart_minus" };
}
return { value: `${summary.score}%`, className: "text-danger", icon: "heart_broken" };
}
if (!value) {
return { value: "N/A", className: "text-slate-500", icon: "heart_broken" };
}
@ -443,6 +455,31 @@
return { value: "Poor", className: "text-danger", icon: "heart_broken" };
};
const reasonLabels = {
no_heartbeat: "No heartbeat",
offline_threshold_exceeded: "Offline threshold",
stale_threshold_exceeded: "Stale heartbeat",
low_signal: "Low signal",
low_battery: "Low battery"
};
const configStatusMeta = (status) => {
const value = normalizeText(status);
if (value === "applied") {
return { label: "Applied", 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" };
}
if (value === "failed_ack") {
return { label: "Failed ACK", className: "bg-danger/10 text-danger border-danger/20", icon: "error" };
}
if (value === "stale_ack") {
return { label: "Stale ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "sync_problem" };
}
return { label: "Never pushed", className: "bg-slate-100 text-slate-600 border-slate-200", icon: "cloud_off" };
};
const statusMeta = (status) => {
const value = normalizeText(status);
if (value === "online") {
@ -459,6 +496,13 @@
dot: "bg-warning"
};
}
if (value === "stale") {
return {
label: "Stale",
className: "bg-warning/10 text-warning border border-warning/20",
dot: "bg-warning"
};
}
return {
label: "Offline",
className: "bg-slate-100 text-slate-500 border border-slate-200",
@ -501,7 +545,7 @@
const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned";
const status = statusMeta(device.derived_status);
const connection = connectionMeta(device.communication_mode);
const health = healthMeta(device.latest_heartbeat);
const health = healthMeta(device.latest_heartbeat, device.health_summary);
return `
<tr class="hover:bg-slate-50 transition-colors group">
@ -649,7 +693,11 @@
const binding = device.binding_summary || {};
const connection = connectionMeta(device.communication_mode);
const status = statusMeta(device.derived_status);
const health = healthMeta(device.latest_heartbeat);
const health = healthMeta(device.latest_heartbeat, device.health_summary);
const summary = device.health_summary || {};
const reasons = Array.isArray(summary.reasons) && summary.reasons.length
? summary.reasons.map((item) => reasonLabels[item] || item).join(", ")
: "No active warning";
const id = device.device_code || device.id || "-";
detailTitle.textContent = id;
@ -683,6 +731,22 @@
<span class="text-on-surface-variant">Health</span>
<span class="${health.className}">${health.value}</span>
</div>
<div class="mt-3 pt-3 border-t border-slate-200">
<div class="flex justify-between mb-2">
<span class="text-on-surface-variant">Heartbeat Age</span>
<span class="font-bold">${typeof summary.age_seconds === "number" ? `${summary.age_seconds}s` : "-"}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-on-surface-variant">Reason</span>
<span class="text-right font-bold">${reasons}</span>
</div>
</div>
</div>
</section>
<section id="device-config-section">
<h5 class="text-label-md font-label-md text-slate-500 uppercase mb-3">Config Delivery</h5>
<div class="bg-slate-50 p-4 rounded-xl border border-slate-100" id="device-config-status-box">
<p class="text-slate-500">Loading config status...</p>
</div>
</section>
<section>
@ -713,6 +777,59 @@
detailOverlay.classList.remove("pointer-events-none", "opacity-0");
detailOverlay.classList.add("opacity-100");
detailDrawer.classList.remove("translate-x-full");
loadDrawerConfig(device.id);
};
const loadDrawerConfig = async (deviceId) => {
const box = document.getElementById("device-config-status-box");
if (!box || !deviceId) {
return;
}
try {
const configStatus = await api.getDeviceConfigStatus(deviceId);
const meta = configStatusMeta(configStatus.drift_status);
const latestPush = configStatus.latest_push;
const latestAck = configStatus.latest_ack;
const canRetry = configStatus.retry_recommended;
box.innerHTML = `
<div class="flex items-center justify-between gap-3 mb-3">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${meta.className}">
<span class="material-symbols-outlined text-[16px]">${meta.icon}</span>
${meta.label}
</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>
<p class="text-slate-500">Latest Push</p>
<p class="font-bold">${latestPush ? formatLastSeen(latestPush.created_at) : "-"}</p>
</div>
<div>
<p class="text-slate-500">Latest ACK</p>
<p class="font-bold">${latestAck ? latestAck.status : "-"}</p>
</div>
</div>
<button id="device-config-retry" class="w-full px-3 py-2 rounded-lg text-sm font-bold border ${canRetry ? "bg-primary text-white border-primary" : "bg-white text-slate-500 border-slate-200"}">
${canRetry ? "Retry Config Push" : "Config Applied"}
</button>
`;
const retryButton = document.getElementById("device-config-retry");
retryButton?.addEventListener("click", async () => {
retryButton.disabled = true;
retryButton.textContent = "Retrying...";
try {
await api.retryDeviceConfigPush(deviceId, canRetry ? {} : { force: true });
await loadDrawerConfig(deviceId);
} catch (error) {
retryButton.disabled = false;
retryButton.textContent = "Retry Failed";
}
});
} catch (error) {
box.innerHTML = '<p class="text-danger">Unable to load config status.</p>';
}
};
const closeDrawer = () => {