Production readiness hardening and ops tooling
This commit is contained in:
@ -126,37 +126,37 @@
|
||||
<p class="text-label-md text-on-surface-variant">Admin Console</p>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined text-[20px]">dashboard</span>
|
||||
<span class="font-body-md">Overview</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/merchant-detail-view">
|
||||
<span class="material-symbols-outlined text-[20px]">storefront</span>
|
||||
<span class="font-body-md">Merchant Management</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 bg-secondary-container text-on-secondary-container font-bold rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 bg-secondary-container text-on-secondary-container font-bold rounded-lg group" href="/ui/device-technical-detail">
|
||||
<span class="material-symbols-outlined text-[20px]">speaker_group</span>
|
||||
<span class="font-body-md">Device Registry</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/transaction-history-monitoring">
|
||||
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
|
||||
<span class="font-body-md">Transactions</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/settlement-batch-management">
|
||||
<span class="material-symbols-outlined text-[20px]">account_balance</span>
|
||||
<span class="font-body-md">Ledger & Settlement</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/admin-reconciliation-management">
|
||||
<span class="material-symbols-outlined text-[20px]">history_edu</span>
|
||||
<span class="font-body-md">Audit Control</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-auto border-t border-slate-100 pt-4 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/hub">
|
||||
<span class="material-symbols-outlined text-[20px]">settings</span>
|
||||
<span class="font-body-md">Settings</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/hub">
|
||||
<span class="material-symbols-outlined text-[20px]">help</span>
|
||||
<span class="font-body-md">Support</span>
|
||||
</a>
|
||||
@ -192,7 +192,7 @@
|
||||
<div class="p-page-padding">
|
||||
<!-- Breadcrumb & Back Action -->
|
||||
<div class="flex items-center gap-2 mb-6 text-on-surface-variant">
|
||||
<a class="flex items-center hover:text-primary transition-colors" href="#">
|
||||
<a class="flex items-center hover:text-primary transition-colors" href="/ui/device-registry-monitoring">
|
||||
<span class="material-symbols-outlined text-sm mr-1">arrow_back</span>
|
||||
<span class="text-label-md">Back to Registry</span>
|
||||
</a>
|
||||
@ -394,6 +394,13 @@ Loading
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-slate-300">chevron_right</span>
|
||||
</button>
|
||||
<button id="rotate-device-credential" class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">vpn_key</span>
|
||||
<span class="font-body-md font-bold">Rotate MQTT Credential</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-slate-300">chevron_right</span>
|
||||
</button>
|
||||
<div class="pt-2">
|
||||
<button class="w-full py-2.5 bg-danger/10 text-danger border border-danger/20 font-bold text-body-md rounded-lg hover:bg-danger/20 transition-all flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined text-[20px]">delete_forever</span>
|
||||
@ -402,6 +409,36 @@ Loading
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- MQTT Credential Panel -->
|
||||
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-card-padding 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">MQTT Credential</p>
|
||||
<h4 class="font-headline-md text-headline-md text-on-surface" id="device-credential-status">Not Issued</h4>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary">key</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold">Username</p>
|
||||
<p class="code-font text-[12px] text-on-surface break-all" id="device-mqtt-username">-</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold">Issued</p>
|
||||
<p class="text-body-md text-on-surface" id="device-credential-issued">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold">Rotated</p>
|
||||
<p class="text-body-md text-on-surface" id="device-credential-rotated">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="rotate-device-credential-secondary" class="w-full py-2.5 bg-primary text-on-primary font-bold text-body-md rounded-lg hover:opacity-90 active:scale-95 transition-all flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined text-[20px]">sync_lock</span>
|
||||
Rotate Credential
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Health Timeline -->
|
||||
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-card-padding shadow-sm">
|
||||
<h4 class="font-headline-md text-headline-md mb-6">Device Events</h4>
|
||||
@ -419,6 +456,50 @@ Loading
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="credential-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/60 px-4">
|
||||
<div class="bg-surface-container-lowest rounded-xl border border-slate-200 shadow-2xl w-full max-w-xl overflow-hidden">
|
||||
<div class="p-card-padding border-b border-slate-100 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="font-headline-md text-headline-md text-on-surface">MQTT Credential Rotated</h3>
|
||||
<p class="text-body-md text-on-surface-variant mt-1">Password ini hanya ditampilkan satu kali.</p>
|
||||
</div>
|
||||
<button id="credential-modal-close" class="w-10 h-10 rounded-lg hover:bg-slate-100 flex items-center justify-center text-on-surface-variant">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-card-padding space-y-4">
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold mb-1">Username</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="credential-modal-username" class="code-font flex-1 bg-slate-100 border border-slate-200 rounded-lg px-3 py-2 text-[13px] break-all">-</code>
|
||||
<button id="credential-copy-username" class="w-10 h-10 rounded-lg border border-slate-200 hover:bg-slate-50 flex items-center justify-center text-on-surface-variant" title="Copy username">
|
||||
<span class="material-symbols-outlined text-[20px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold mb-1">Password</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="credential-modal-password" class="code-font flex-1 bg-slate-900 text-green-400 rounded-lg px-3 py-2 text-[13px] break-all">-</code>
|
||||
<button id="credential-copy-password" class="w-10 h-10 rounded-lg border border-slate-200 hover:bg-slate-50 flex items-center justify-center text-on-surface-variant" title="Copy password">
|
||||
<span class="material-symbols-outlined text-[20px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold mb-1">Mosquitto Command</p>
|
||||
<code id="credential-modal-command" class="code-font block bg-slate-100 border border-slate-200 rounded-lg px-3 py-2 text-[12px] break-all">-</code>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button id="credential-copy-command" class="px-4 py-2 border border-slate-200 rounded-lg font-bold text-body-md hover:bg-slate-50 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-[20px]">terminal</span>
|
||||
Copy Command
|
||||
</button>
|
||||
<button id="credential-modal-done" class="px-4 py-2 bg-primary text-on-primary rounded-lg font-bold text-body-md hover:opacity-90">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/shared/admin-api.js"></script>
|
||||
<script>
|
||||
const DeviceDetail = (() => {
|
||||
@ -429,13 +510,25 @@ Loading
|
||||
|
||||
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 clearBtn = document.getElementById("clearConsole");
|
||||
const exportBtn = document.getElementById("export-device-logs");
|
||||
const refreshBtn = document.getElementById("refresh-device-state");
|
||||
const viewAllEventsBtn = document.getElementById("view-all-events");
|
||||
const rotateCredentialButtons = [
|
||||
document.getElementById("rotate-device-credential"),
|
||||
document.getElementById("rotate-device-credential-secondary")
|
||||
].filter(Boolean);
|
||||
const credentialModal = document.getElementById("credential-modal");
|
||||
const credentialModalClose = document.getElementById("credential-modal-close");
|
||||
const credentialModalDone = document.getElementById("credential-modal-done");
|
||||
const credentialCopyUsername = document.getElementById("credential-copy-username");
|
||||
const credentialCopyPassword = document.getElementById("credential-copy-password");
|
||||
const credentialCopyCommand = document.getElementById("credential-copy-command");
|
||||
const backLink = document.querySelector("a[href='#']");
|
||||
const eventHost = document.getElementById("device-events");
|
||||
let latestCredentialCommand = "";
|
||||
|
||||
const els = {
|
||||
breadcrumbCode: document.getElementById("device-breadcrumb-code"),
|
||||
@ -460,7 +553,14 @@ Loading
|
||||
configVersion: document.getElementById("device-config-version"),
|
||||
configStatus: document.getElementById("device-config-status"),
|
||||
configDetail: document.getElementById("device-config-detail"),
|
||||
configRetry: document.getElementById("device-config-retry")
|
||||
configRetry: document.getElementById("device-config-retry"),
|
||||
credentialStatus: document.getElementById("device-credential-status"),
|
||||
mqttUsername: document.getElementById("device-mqtt-username"),
|
||||
credentialIssued: document.getElementById("device-credential-issued"),
|
||||
credentialRotated: document.getElementById("device-credential-rotated"),
|
||||
credentialModalUsername: document.getElementById("credential-modal-username"),
|
||||
credentialModalPassword: document.getElementById("credential-modal-password"),
|
||||
credentialModalCommand: document.getElementById("credential-modal-command")
|
||||
};
|
||||
|
||||
const setText = (el, value, fallback = "-") => {
|
||||
@ -740,12 +840,79 @@ Loading
|
||||
}
|
||||
};
|
||||
|
||||
const shellQuote = (value) => `'${String(value || "").replace(/'/g, "'\\''")}'`;
|
||||
|
||||
const renderCredentialSummary = (device) => {
|
||||
const status = device?.credential_status || "not_issued";
|
||||
const statusLabel = String(status).replace(/_/g, " ").toUpperCase();
|
||||
setText(els.credentialStatus, statusLabel);
|
||||
if (els.credentialStatus) {
|
||||
els.credentialStatus.className = `font-headline-md text-headline-md ${status === "active" ? "text-success" : status === "revoked" ? "text-danger" : "text-on-surface"}`;
|
||||
}
|
||||
setText(els.mqttUsername, device?.mqtt_username || device?.id || "-");
|
||||
setText(els.credentialIssued, formatDateTime(device?.credential_issued_at, "-"));
|
||||
setText(els.credentialRotated, formatDateTime(device?.credential_rotated_at, "-"));
|
||||
};
|
||||
|
||||
const setRotateLoading = (loading) => {
|
||||
rotateCredentialButtons.forEach((button) => {
|
||||
button.disabled = loading;
|
||||
button.classList.toggle("opacity-60", loading);
|
||||
const label = button.querySelector(".font-body-md, .text-body-md");
|
||||
if (label) {
|
||||
label.textContent = loading ? "Rotating..." : "Rotate MQTT Credential";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const showCredentialModal = (credential) => {
|
||||
const username = credential?.mqtt_username || "";
|
||||
const password = credential?.mqtt_password || "";
|
||||
latestCredentialCommand = `sudo mosquitto_passwd -b /etc/mosquitto/passwd ${shellQuote(username)} ${shellQuote(password)}`;
|
||||
setText(els.credentialModalUsername, username);
|
||||
setText(els.credentialModalPassword, password);
|
||||
setText(els.credentialModalCommand, latestCredentialCommand);
|
||||
credentialModal?.classList.remove("hidden");
|
||||
credentialModal?.classList.add("flex");
|
||||
};
|
||||
|
||||
const closeCredentialModal = () => {
|
||||
credentialModal?.classList.add("hidden");
|
||||
credentialModal?.classList.remove("flex");
|
||||
setText(els.credentialModalPassword, "-");
|
||||
latestCredentialCommand = "";
|
||||
};
|
||||
|
||||
const copyText = async (value) => {
|
||||
if (!value || !navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const rotateCredential = async () => {
|
||||
if (!activeDeviceId) {
|
||||
return;
|
||||
}
|
||||
setRotateLoading(true);
|
||||
try {
|
||||
const result = await api.rotateDeviceCredential(activeDeviceId);
|
||||
renderCredentialSummary(result.device);
|
||||
showCredentialModal(result.credential);
|
||||
} catch (error) {
|
||||
console.error("[device detail] credential rotate failed", error);
|
||||
setText(els.credentialStatus, "ROTATE FAILED");
|
||||
} finally {
|
||||
setRotateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfigStatus = async () => {
|
||||
if (!deviceId) {
|
||||
if (!activeDeviceId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await api.getDeviceConfigStatus(deviceId);
|
||||
const status = await api.getDeviceConfigStatus(activeDeviceId);
|
||||
renderConfigStatus(status);
|
||||
} catch (error) {
|
||||
setText(els.configDetail, "Unable to load config status.");
|
||||
@ -789,23 +956,35 @@ Loading
|
||||
const loadDevice = async () => {
|
||||
try {
|
||||
api.requireToken();
|
||||
if (!deviceId) {
|
||||
let selectedDeviceId = deviceId;
|
||||
if (!selectedDeviceId) {
|
||||
const devices = await api.listDevices();
|
||||
selectedDeviceId = Array.isArray(devices) && devices.length ? devices[0].id : "";
|
||||
}
|
||||
activeDeviceId = selectedDeviceId;
|
||||
|
||||
if (!selectedDeviceId) {
|
||||
setText(els.breadcrumbCode, "Missing device_id");
|
||||
setText(els.title, "Missing device");
|
||||
return;
|
||||
}
|
||||
|
||||
const [device, heartbeats] = await Promise.all([
|
||||
api.getDevice(deviceId),
|
||||
const [device, heartbeatResponse] = await Promise.all([
|
||||
api.getDevice(selectedDeviceId),
|
||||
(async () => {
|
||||
try {
|
||||
return await api.getDeviceHeartbeats(deviceId);
|
||||
return await api.getDeviceHeartbeats(selectedDeviceId);
|
||||
} catch (error) {
|
||||
console.warn("[device detail] heartbeat fetch failed", error);
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
]);
|
||||
const heartbeats = Array.isArray(heartbeatResponse)
|
||||
? heartbeatResponse
|
||||
: Array.isArray(heartbeatResponse?.heartbeats)
|
||||
? heartbeatResponse.heartbeats
|
||||
: [];
|
||||
|
||||
const modelCode = device.device_code || device.code || device.serial_number || device.id || "Unknown Device";
|
||||
setText(els.breadcrumbCode, modelCode);
|
||||
@ -824,6 +1003,7 @@ Loading
|
||||
const latestMetric = extractHeartbeatMetrics(latest);
|
||||
setDerivedStatus(device, heartbeats);
|
||||
renderHealthSummary(device.health_summary);
|
||||
renderCredentialSummary(device);
|
||||
setText(
|
||||
els.firmwareVersion,
|
||||
device.firmware_version || device.fw_version || device.firmware || "-"
|
||||
@ -841,18 +1021,29 @@ Loading
|
||||
};
|
||||
|
||||
refreshBtn?.addEventListener("click", loadDevice);
|
||||
rotateCredentialButtons.forEach((button) => button.addEventListener("click", rotateCredential));
|
||||
credentialModalClose?.addEventListener("click", closeCredentialModal);
|
||||
credentialModalDone?.addEventListener("click", closeCredentialModal);
|
||||
credentialModal?.addEventListener("click", (event) => {
|
||||
if (event.target === credentialModal) {
|
||||
closeCredentialModal();
|
||||
}
|
||||
});
|
||||
credentialCopyUsername?.addEventListener("click", () => copyText(els.credentialModalUsername?.textContent || ""));
|
||||
credentialCopyPassword?.addEventListener("click", () => copyText(els.credentialModalPassword?.textContent || ""));
|
||||
credentialCopyCommand?.addEventListener("click", () => copyText(latestCredentialCommand));
|
||||
els.configRetry?.addEventListener("click", async () => {
|
||||
if (!deviceId || !els.configRetry) {
|
||||
if (!activeDeviceId || !els.configRetry) {
|
||||
return;
|
||||
}
|
||||
els.configRetry.disabled = true;
|
||||
els.configRetry.textContent = "Retrying...";
|
||||
try {
|
||||
await api.retryDeviceConfigPush(deviceId, {});
|
||||
await api.retryDeviceConfigPush(activeDeviceId, {});
|
||||
await loadConfigStatus();
|
||||
} catch (error) {
|
||||
try {
|
||||
await api.retryDeviceConfigPush(deviceId, { force: true });
|
||||
await api.retryDeviceConfigPush(activeDeviceId, { force: true });
|
||||
await loadConfigStatus();
|
||||
} catch (retryError) {
|
||||
els.configRetry.textContent = "Retry Failed";
|
||||
|
||||
Reference in New Issue
Block a user