Continue phase 2 device ops and dynamic QR lifecycle
This commit is contained in:
@ -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 = () => {
|
||||
|
||||
@ -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>';
|
||||
|
||||
@ -135,6 +135,13 @@ window.AdminUIAPI = {
|
||||
getDevice: (id) => adminFetch(`/admin/devices/${id}`),
|
||||
getDeviceHeartbeats: (id, query) =>
|
||||
adminFetch(`/admin/devices/${id}/heartbeats`, { query }),
|
||||
getDeviceConfig: (id) => adminFetch(`/admin/devices/${id}/config`),
|
||||
getDeviceConfigStatus: (id) => adminFetch(`/admin/devices/${id}/config/status`),
|
||||
retryDeviceConfigPush: (id, payload) =>
|
||||
adminFetch(`/admin/devices/${id}/config/retry-push`, {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
listTransactions: (query) => adminFetch("/admin/transactions", { query }),
|
||||
getDashboardSummary: () => adminFetch("/admin/dashboard/summary"),
|
||||
formatMoney,
|
||||
|
||||
Reference in New Issue
Block a user