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

@ -287,6 +287,38 @@
</p>
</div>
</div>
<!-- Fase 2 Ops Summary -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-gutter">
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<p class="text-label-md text-on-surface-variant mb-1">Health Summary</p>
<h3 class="font-headline-lg text-headline-lg text-on-surface" id="device-health-score">-</h3>
</div>
<span id="device-health-status" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border bg-slate-100 text-slate-600 border-slate-200">
<span class="material-symbols-outlined text-[16px]">monitor_heart</span>
Unknown
</span>
</div>
<p class="text-body-md text-on-surface-variant" id="device-health-reasons">No health summary yet.</p>
</div>
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<p class="text-label-md text-on-surface-variant mb-1">Config Delivery</p>
<h3 class="font-headline-md text-headline-md text-on-surface" id="device-config-version">Config -</h3>
</div>
<span id="device-config-status" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border bg-slate-100 text-slate-600 border-slate-200">
<span class="material-symbols-outlined text-[16px]">pending</span>
Loading
</span>
</div>
<div class="flex items-center justify-between gap-3">
<p class="text-body-md text-on-surface-variant" id="device-config-detail">Waiting for config status.</p>
<button id="device-config-retry" class="px-3 py-2 bg-primary text-white rounded-lg text-label-md font-bold hover:opacity-90 disabled:opacity-50">Retry Push</button>
</div>
</div>
</div>
<!-- Merchant Binding Info -->
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl overflow-hidden">
<div class="px-card-padding py-4 border-b border-slate-100 flex justify-between items-center bg-surface-container-low">
@ -421,7 +453,14 @@
firmwareStatus: document.getElementById("device-firmware-status"),
bindingMerchant: document.getElementById("device-binding-merchant"),
bindingMerchantId: document.getElementById("device-binding-merchant-id"),
bindingSince: document.getElementById("device-binding-since")
bindingSince: document.getElementById("device-binding-since"),
healthScore: document.getElementById("device-health-score"),
healthStatus: document.getElementById("device-health-status"),
healthReasons: document.getElementById("device-health-reasons"),
configVersion: document.getElementById("device-config-version"),
configStatus: document.getElementById("device-config-status"),
configDetail: document.getElementById("device-config-detail"),
configRetry: document.getElementById("device-config-retry")
};
const setText = (el, value, fallback = "-") => {
@ -500,8 +539,8 @@
return {};
}
return {
signal: heartbeat.rssi ?? heartbeat.rssi_dbm ?? heartbeat.signal ?? heartbeat.signal_strength ?? heartbeat.rsrp,
battery: heartbeat.battery_voltage ?? heartbeat.v_batt ?? heartbeat.voltage ?? heartbeat.battery ?? heartbeat.batt ?? null,
signal: heartbeat.network_strength ?? heartbeat.rssi ?? heartbeat.rssi_dbm ?? heartbeat.signal ?? heartbeat.signal_strength ?? heartbeat.rsrp,
battery: heartbeat.battery_level ?? heartbeat.battery_voltage ?? heartbeat.v_batt ?? heartbeat.voltage ?? heartbeat.battery ?? heartbeat.batt ?? null,
ts: heartbeat.ts ?? heartbeat.timestamp ?? heartbeat.created_at ?? heartbeat.updated_at
};
};
@ -633,6 +672,89 @@
}
};
const healthReasonLabels = {
no_heartbeat: "No heartbeat",
offline_threshold_exceeded: "Offline threshold exceeded",
stale_threshold_exceeded: "Heartbeat is stale",
low_signal: "Low signal",
low_battery: "Low battery"
};
const statusPillClass = (status) => {
const normalized = String(status || "").toLowerCase();
if (normalized === "online" || normalized === "applied") {
return "bg-success/10 text-success border-success/20";
}
if (normalized === "degraded" || normalized === "stale" || normalized === "pending_ack" || normalized === "stale_ack") {
return "bg-warning/10 text-warning border-warning/20";
}
if (normalized === "offline" || normalized === "failed_ack") {
return "bg-danger/10 text-danger border-danger/20";
}
return "bg-slate-100 text-slate-600 border-slate-200";
};
const renderHealthSummary = (summary) => {
if (!summary) {
setText(els.healthScore, "-");
setText(els.healthReasons, "No health summary yet.");
return;
}
setText(els.healthScore, typeof summary.score === "number" ? `${summary.score}%` : "-");
const status = summary.status || "unknown";
if (els.healthStatus) {
els.healthStatus.className = `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${statusPillClass(status)}`;
els.healthStatus.innerHTML = `<span class="material-symbols-outlined text-[16px]">monitor_heart</span>${String(status).replace("_", " ").toUpperCase()}`;
}
const reasons = Array.isArray(summary.reasons) && summary.reasons.length
? summary.reasons.map((item) => healthReasonLabels[item] || item).join(", ")
: "No active warning";
setText(
els.healthReasons,
`${reasons}${typeof summary.age_seconds === "number" ? ` · ${summary.age_seconds}s since heartbeat` : ""}`
);
};
const renderConfigStatus = (status) => {
if (!status) {
return;
}
const drift = status.drift_status || "unknown";
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";
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}`);
if (els.configRetry) {
els.configRetry.disabled = status.retry_recommended === false;
els.configRetry.textContent = status.retry_recommended === false ? "Applied" : "Retry Push";
}
};
const loadConfigStatus = async () => {
if (!deviceId) {
return;
}
try {
const status = await api.getDeviceConfigStatus(deviceId);
renderConfigStatus(status);
} catch (error) {
setText(els.configDetail, "Unable to load config status.");
if (els.configRetry) {
els.configRetry.disabled = true;
}
}
};
const loadBindingDetails = async (device) => {
const binding = device?.binding_summary || {};
let merchantName = binding.merchant_id || "Unbound";
@ -701,6 +823,7 @@
const latestMetric = extractHeartbeatMetrics(latest);
setDerivedStatus(device, heartbeats);
renderHealthSummary(device.health_summary);
setText(
els.firmwareVersion,
device.firmware_version || device.fw_version || device.firmware || "-"
@ -710,6 +833,7 @@
renderStream(heartbeats);
renderEvents(heartbeats);
await loadBindingDetails(device);
await loadConfigStatus();
} catch (error) {
console.error("[device detail] failed loading", error);
setText(els.title, "Unable to load device");
@ -717,6 +841,24 @@
};
refreshBtn?.addEventListener("click", loadDevice);
els.configRetry?.addEventListener("click", async () => {
if (!deviceId || !els.configRetry) {
return;
}
els.configRetry.disabled = true;
els.configRetry.textContent = "Retrying...";
try {
await api.retryDeviceConfigPush(deviceId, {});
await loadConfigStatus();
} catch (error) {
try {
await api.retryDeviceConfigPush(deviceId, { force: true });
await loadConfigStatus();
} catch (retryError) {
els.configRetry.textContent = "Retry Failed";
}
}
});
clearBtn?.addEventListener("click", () => {
if (stream) {
stream.innerHTML = '<p class="text-slate-500">--- CONSOLE CLEARED ---</p>';