|
|
|
|
@ -9,6 +9,8 @@ const state = {
@@ -9,6 +9,8 @@ const state = {
|
|
|
|
|
pages: 1, |
|
|
|
|
selectedId: null, |
|
|
|
|
searchTimer: null, |
|
|
|
|
options: { device_status: [], asset_for: [], asset_on: [] }, |
|
|
|
|
ldapSearchTimer: null, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const el = { |
|
|
|
|
@ -75,6 +77,34 @@ async function api(path) {
@@ -75,6 +77,34 @@ async function api(path) {
|
|
|
|
|
return data; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
async function apiMutate(method, path, body) { |
|
|
|
|
const url = `${state.baseUrl.replace(/\/$/, "")}${path}`; |
|
|
|
|
const res = await fetch(url, { |
|
|
|
|
method, |
|
|
|
|
headers: { |
|
|
|
|
Authorization: `Bearer ${state.token}`, |
|
|
|
|
"Content-Type": "application/json", |
|
|
|
|
}, |
|
|
|
|
body: JSON.stringify(body), |
|
|
|
|
}); |
|
|
|
|
const text = await res.text(); |
|
|
|
|
let data; |
|
|
|
|
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } |
|
|
|
|
if (!res.ok) { |
|
|
|
|
const msg = data?.detail?.error?.message || data?.detail || res.statusText; |
|
|
|
|
throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); |
|
|
|
|
} |
|
|
|
|
return data; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
async function loadOptions() { |
|
|
|
|
try { |
|
|
|
|
state.options = await api("/api/v1/assets/options"); |
|
|
|
|
} catch { |
|
|
|
|
state.options = { device_status: [], asset_for: [], asset_on: [] }; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function fmtDate(value) { |
|
|
|
|
if (!value) return "—"; |
|
|
|
|
try { |
|
|
|
|
@ -128,6 +158,185 @@ function buildAssetsQuery() {
@@ -128,6 +158,185 @@ function buildAssetsQuery() {
|
|
|
|
|
return `?${params.toString()}`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function assetLabel(item) { |
|
|
|
|
return item.display_name || item.hostname; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function formFactorBadge(value) { |
|
|
|
|
if (!value) return ""; |
|
|
|
|
const label = value === "laptop" ? "Laptop" : value === "desktop" ? "PC" : value; |
|
|
|
|
const cls = value === "laptop" ? "form-laptop" : value === "desktop" ? "form-desktop" : "form-other"; |
|
|
|
|
return `<span class="badge ${cls}">${escapeHtml(label)}</span>`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const DEVICE_STATUS_COLORS = { |
|
|
|
|
active: "ds-active", used: "ds-used", stok: "ds-stok", |
|
|
|
|
rusak: "ds-rusak", maintenance: "ds-maintenance", |
|
|
|
|
hilang: "ds-hilang", retired: "ds-retired", pinjam: "ds-pinjam", |
|
|
|
|
}; |
|
|
|
|
const ASSET_FOR_COLORS = { |
|
|
|
|
geonet: "af-geonet", sewa: "af-sewa", proyek: "af-proyek", |
|
|
|
|
personal: "af-personal", client: "af-client", inventaris: "af-inventaris", |
|
|
|
|
}; |
|
|
|
|
const ASSET_ON_COLORS = { |
|
|
|
|
geonet: "ao-geonet", site: "ao-site", remote: "ao-remote", |
|
|
|
|
gudang: "ao-gudang", client: "ao-client", lab: "ao-lab", |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
function tagBadge(value, colorMap) { |
|
|
|
|
if (!value) return ""; |
|
|
|
|
const cls = colorMap[value] || "tag-default"; |
|
|
|
|
return `<span class="badge ${cls}">${escapeHtml(value)}</span>`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function buildSelect(name, options, current, placeholder = "— pilih —") { |
|
|
|
|
let html = `<select id="edit-${name}" data-field="${name}">`; |
|
|
|
|
html += `<option value="">${placeholder}</option>`; |
|
|
|
|
for (const opt of options) { |
|
|
|
|
const sel = opt === current ? " selected" : ""; |
|
|
|
|
html += `<option value="${escapeHtml(opt)}"${sel}>${escapeHtml(opt)}</option>`; |
|
|
|
|
} |
|
|
|
|
html += `</select>`; |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderEditPanel(asset) { |
|
|
|
|
const opts = state.options; |
|
|
|
|
return ` |
|
|
|
|
<div class="edit-panel" id="edit-panel"> |
|
|
|
|
<div class="edit-panel-title">⚙ Edit Asset</div> |
|
|
|
|
|
|
|
|
|
<div class="edit-section"> |
|
|
|
|
<div class="edit-label">Display Name</div> |
|
|
|
|
<input type="text" id="edit-display_name" data-field="display_name" |
|
|
|
|
value="${escapeHtml(asset.display_name || "")}" |
|
|
|
|
placeholder="Nama tampilan (opsional)" /> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="edit-section"> |
|
|
|
|
<div class="edit-label">Owner (LDAP Username)</div> |
|
|
|
|
<div class="ldap-autocomplete"> |
|
|
|
|
<input type="text" id="edit-owner-input" |
|
|
|
|
value="${escapeHtml(asset.owner_ldap_username || "")}" |
|
|
|
|
placeholder="Ketik username / nama... (min 2 karakter)" |
|
|
|
|
autocomplete="off" /> |
|
|
|
|
<div id="ldap-suggestions" class="ldap-dropdown hidden"></div> |
|
|
|
|
</div> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="edit-section"> |
|
|
|
|
<div class="edit-label">Device Status</div> |
|
|
|
|
${buildSelect("device_status", opts.device_status || [], asset.device_status)} |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="edit-section"> |
|
|
|
|
<div class="edit-label">Asset For (Kepemilikan)</div> |
|
|
|
|
${buildSelect("asset_for", opts.asset_for || [], asset.asset_for)} |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="edit-section"> |
|
|
|
|
<div class="edit-label">Asset On (Lokasi)</div> |
|
|
|
|
${buildSelect("asset_on", opts.asset_on || [], asset.asset_on)} |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="edit-section"> |
|
|
|
|
<div class="edit-label">Catatan</div> |
|
|
|
|
<textarea id="edit-notes" data-field="notes" rows="3" |
|
|
|
|
placeholder="Catatan tambahan...">${escapeHtml(asset.notes || "")}</textarea> |
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
<div class="edit-actions"> |
|
|
|
|
<button type="button" id="save-meta-btn">Simpan</button> |
|
|
|
|
<span id="edit-status" class="edit-status"></span> |
|
|
|
|
</div> |
|
|
|
|
</div>`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function bindEditPanel(asset) { |
|
|
|
|
const ownerInput = document.getElementById("edit-owner-input"); |
|
|
|
|
const suggestionsBox = document.getElementById("ldap-suggestions"); |
|
|
|
|
const saveBtn = document.getElementById("save-meta-btn"); |
|
|
|
|
const editStatus = document.getElementById("edit-status"); |
|
|
|
|
|
|
|
|
|
if (ownerInput) { |
|
|
|
|
ownerInput.addEventListener("input", () => { |
|
|
|
|
clearTimeout(state.ldapSearchTimer); |
|
|
|
|
const q = ownerInput.value.trim(); |
|
|
|
|
if (q.length < 2) { |
|
|
|
|
suggestionsBox.classList.add("hidden"); |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
state.ldapSearchTimer = setTimeout(async () => { |
|
|
|
|
try { |
|
|
|
|
const results = await api(`/api/v1/ldap/users/search?q=${encodeURIComponent(q)}&limit=8`); |
|
|
|
|
if (!results.length) { |
|
|
|
|
suggestionsBox.innerHTML = `<div class="ldap-item muted">Tidak ada hasil.</div>`; |
|
|
|
|
} else { |
|
|
|
|
suggestionsBox.innerHTML = results.map(u => ` |
|
|
|
|
<div class="ldap-item" data-username="${escapeHtml(u.username)}"> |
|
|
|
|
<strong>${escapeHtml(u.username)}</strong> |
|
|
|
|
${u.display_name ? `<span class="muted"> — ${escapeHtml(u.display_name)}</span>` : ""} |
|
|
|
|
${u.department ? `<span class="muted small"> · ${escapeHtml(u.department)}</span>` : ""} |
|
|
|
|
</div>`).join(""); |
|
|
|
|
suggestionsBox.querySelectorAll(".ldap-item").forEach(item => { |
|
|
|
|
item.addEventListener("click", () => { |
|
|
|
|
ownerInput.value = item.dataset.username; |
|
|
|
|
suggestionsBox.classList.add("hidden"); |
|
|
|
|
}); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
suggestionsBox.classList.remove("hidden"); |
|
|
|
|
} catch { |
|
|
|
|
suggestionsBox.classList.add("hidden"); |
|
|
|
|
} |
|
|
|
|
}, 300); |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
document.addEventListener("click", (e) => { |
|
|
|
|
if (!e.target.closest(".ldap-autocomplete")) { |
|
|
|
|
suggestionsBox.classList.add("hidden"); |
|
|
|
|
} |
|
|
|
|
}, { once: false }); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (saveBtn) { |
|
|
|
|
saveBtn.addEventListener("click", async () => { |
|
|
|
|
saveBtn.disabled = true; |
|
|
|
|
editStatus.textContent = "Menyimpan..."; |
|
|
|
|
editStatus.className = "edit-status"; |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
const metaBody = {}; |
|
|
|
|
["display_name", "device_status", "asset_for", "asset_on"].forEach(field => { |
|
|
|
|
const el = document.getElementById(`edit-${field}`); |
|
|
|
|
if (el) metaBody[field] = el.value || null; |
|
|
|
|
}); |
|
|
|
|
const notesEl = document.getElementById("edit-notes"); |
|
|
|
|
if (notesEl) metaBody.notes = notesEl.value || null; |
|
|
|
|
|
|
|
|
|
await apiMutate("PATCH", `/api/v1/assets/${asset.id}/meta`, metaBody); |
|
|
|
|
|
|
|
|
|
const newOwner = ownerInput?.value?.trim() || ""; |
|
|
|
|
const prevOwner = asset.owner_ldap_username || ""; |
|
|
|
|
if (newOwner !== prevOwner) { |
|
|
|
|
if (newOwner) { |
|
|
|
|
await apiMutate("PUT", `/api/v1/assets/${asset.id}/owner`, { ldap_username: newOwner }); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
editStatus.textContent = "✓ Tersimpan"; |
|
|
|
|
editStatus.className = "edit-status ok"; |
|
|
|
|
setTimeout(() => openDetail(asset.id), 800); |
|
|
|
|
} catch (err) { |
|
|
|
|
editStatus.textContent = `✗ ${err.message}`; |
|
|
|
|
editStatus.className = "edit-status error"; |
|
|
|
|
} finally { |
|
|
|
|
saveBtn.disabled = false; |
|
|
|
|
} |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderAssets(data) { |
|
|
|
|
state.total = data.total; |
|
|
|
|
state.pages = data.pages || 1; |
|
|
|
|
@ -143,12 +352,15 @@ function renderAssets(data) {
@@ -143,12 +352,15 @@ function renderAssets(data) {
|
|
|
|
|
const rows = data.items |
|
|
|
|
.map((item) => { |
|
|
|
|
const selected = item.id === state.selectedId ? "selected" : ""; |
|
|
|
|
const dsBadge = item.device_status ? tagBadge(item.device_status, DEVICE_STATUS_COLORS) : ""; |
|
|
|
|
const afBadge = item.asset_for ? tagBadge(item.asset_for, ASSET_FOR_COLORS) : ""; |
|
|
|
|
const aoBadge = item.asset_on ? tagBadge(item.asset_on, ASSET_ON_COLORS) : ""; |
|
|
|
|
return `<tr data-id="${escapeHtml(item.id)}" class="${selected}">
|
|
|
|
|
<td><strong>${escapeHtml(item.hostname)}</strong></td> |
|
|
|
|
<td><strong>${escapeHtml(assetLabel(item))}</strong>${item.display_name ? `<div class="muted small">${escapeHtml(item.hostname)}</div>` : ""}${formFactorBadge(item.form_factor)}</td> |
|
|
|
|
<td><span class="badge endpoint">${escapeHtml(item.asset_type)}</span></td> |
|
|
|
|
<td><span class="badge active">${escapeHtml(item.status)}</span></td> |
|
|
|
|
<td>${dsBadge || `<span class="badge active">${escapeHtml(item.status)}</span>`}</td> |
|
|
|
|
<td>${afBadge} ${aoBadge}</td> |
|
|
|
|
<td class="muted">${escapeHtml(item.serial_number || "—")}</td> |
|
|
|
|
<td>${escapeHtml(item.software_count ?? "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(fmtDate(item.last_seen_at))}</td> |
|
|
|
|
</tr>`; |
|
|
|
|
}) |
|
|
|
|
@ -162,8 +374,8 @@ function renderAssets(data) {
@@ -162,8 +374,8 @@ function renderAssets(data) {
|
|
|
|
|
<th>Hostname</th> |
|
|
|
|
<th>Tipe</th> |
|
|
|
|
<th>Status</th> |
|
|
|
|
<th>Tag</th> |
|
|
|
|
<th>Serial</th> |
|
|
|
|
<th>Software</th> |
|
|
|
|
<th>Last seen</th> |
|
|
|
|
</tr> |
|
|
|
|
</thead> |
|
|
|
|
@ -193,83 +405,215 @@ function connectionLabel(value) {
@@ -193,83 +405,215 @@ function connectionLabel(value) {
|
|
|
|
|
return labels[value] || value || "—"; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderDetail(asset, software) { |
|
|
|
|
const snap = asset.latest_snapshot; |
|
|
|
|
const identity = [ |
|
|
|
|
["Hostname", asset.hostname], |
|
|
|
|
["Asset ID", asset.id], |
|
|
|
|
["Serial", asset.serial_number], |
|
|
|
|
["SMBIOS UUID", asset.smbios_uuid], |
|
|
|
|
["Machine ID", asset.machine_id], |
|
|
|
|
["MAC", asset.primary_physical_mac], |
|
|
|
|
["Manufacturer", asset.manufacturer], |
|
|
|
|
["Model", asset.model], |
|
|
|
|
["Snapshots", asset.snapshot_count], |
|
|
|
|
["Last seen", fmtDate(asset.last_seen_at)], |
|
|
|
|
]; |
|
|
|
|
function renderMemorySection(snap) { |
|
|
|
|
const summary = snap.memory_summary; |
|
|
|
|
const modules = snap.memory_modules || []; |
|
|
|
|
if (!summary && !modules.length) return ""; |
|
|
|
|
|
|
|
|
|
let html = `<div class="section-title">Memory (RAM)</div>`; |
|
|
|
|
if (summary) { |
|
|
|
|
const layout = summary.layout || summary.configuration || "—"; |
|
|
|
|
const moduleLabel = |
|
|
|
|
summary.module_count != null |
|
|
|
|
? `${summary.module_count} modul` |
|
|
|
|
: modules.length |
|
|
|
|
? `${modules.length} modul` |
|
|
|
|
: "—"; |
|
|
|
|
html += `<dl class="detail-grid memory-summary">
|
|
|
|
|
<dt>Total</dt><dd>${escapeHtml(summary.total_gb != null ? `${summary.total_gb} GB` : snap.ram_gb != null ? `${snap.ram_gb} GB` : "—")}</dd> |
|
|
|
|
<dt>Layout</dt><dd>${escapeHtml(layout)}</dd> |
|
|
|
|
<dt>Generasi</dt><dd>${escapeHtml(summary.ddr_generation || "—")}</dd> |
|
|
|
|
<dt>Modul</dt><dd>${escapeHtml(moduleLabel)}</dd> |
|
|
|
|
<dt>Slot kosong</dt><dd>${escapeHtml(summary.empty_slots != null ? summary.empty_slots : "—")}</dd> |
|
|
|
|
</dl>`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let html = `<dl class="detail-grid">${identity |
|
|
|
|
.map( |
|
|
|
|
([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v || "—")}</dd>` |
|
|
|
|
) |
|
|
|
|
.join("")}</dl>`; |
|
|
|
|
if (modules.length) { |
|
|
|
|
html += `<table><thead><tr><th>Slot</th><th>Kapasitas</th><th>Tipe</th><th>Speed</th><th>Form</th><th>Merek</th><th>Part no.</th><th>Serial</th></tr></thead><tbody>`; |
|
|
|
|
html += modules |
|
|
|
|
.map((m) => { |
|
|
|
|
const cap = m.capacity_gb != null ? `${m.capacity_gb} GB` : "—"; |
|
|
|
|
const spd = m.configured_speed_mhz != null ? `${m.configured_speed_mhz} MHz` : m.speed_mhz != null ? `${m.speed_mhz} MHz` : "—"; |
|
|
|
|
return `<tr>
|
|
|
|
|
<td>${escapeHtml(m.slot || m.bank_label || "—")}</td> |
|
|
|
|
<td>${escapeHtml(cap)}</td> |
|
|
|
|
<td>${escapeHtml(m.memory_type_label || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(spd)}</td> |
|
|
|
|
<td class="muted">${escapeHtml(m.form_factor || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(m.manufacturer || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(m.part_number || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(m.serial_number || "—")}</td> |
|
|
|
|
</tr>`; |
|
|
|
|
}) |
|
|
|
|
.join(""); |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
} |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (snap) { |
|
|
|
|
html += `<div class="section-title">Snapshot terbaru — ${escapeHtml(fmtDate(snap.collected_at))}</div>`; |
|
|
|
|
html += `<dl class="detail-grid">
|
|
|
|
|
<dt>OS</dt><dd>${escapeHtml(snap.os_name || "—")} ${escapeHtml(snap.os_version || "")}</dd> |
|
|
|
|
<dt>CPU</dt><dd>${escapeHtml(snap.cpu_model || "—")}</dd> |
|
|
|
|
<dt>RAM</dt><dd>${escapeHtml(snap.ram_gb != null ? `${snap.ram_gb} GB` : "—")}</dd> |
|
|
|
|
<dt>GPU</dt><dd>${escapeHtml(snap.gpu_model || "—")}</dd> |
|
|
|
|
<dt>Agent</dt><dd>${escapeHtml(snap.agent_version)}</dd> |
|
|
|
|
</dl>`; |
|
|
|
|
function renderCpuSection(snap) { |
|
|
|
|
const cpu = snap.cpu; |
|
|
|
|
if (!cpu) { |
|
|
|
|
if (!snap.cpu_model) return ""; |
|
|
|
|
return `<div class="section-title">Processor (CPU)</div>
|
|
|
|
|
<table><thead><tr><th>Model</th></tr></thead><tbody> |
|
|
|
|
<tr><td>${escapeHtml(snap.cpu_model)}</td></tr> |
|
|
|
|
</tbody></table>`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const fmtKb = (v) => (v != null ? `${v} KB` : "—"); |
|
|
|
|
|
|
|
|
|
let html = `<div class="section-title">Processor (CPU)</div>`; |
|
|
|
|
html += `<table><thead><tr><th>Model</th><th>Vendor</th><th>Cores</th><th>Threads</th><th>Clock</th><th>Cache</th><th>Socket</th><th>Arch</th></tr></thead><tbody>`; |
|
|
|
|
html += `<tr>
|
|
|
|
|
<td><strong>${escapeHtml(cpu.model || snap.cpu_model || "—")}</strong></td> |
|
|
|
|
<td class="muted">${escapeHtml(cpu.manufacturer || "—")}</td> |
|
|
|
|
<td>${escapeHtml(cpu.cores ?? "—")}</td> |
|
|
|
|
<td>${escapeHtml(cpu.logical_processors ?? "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml( |
|
|
|
|
cpu.max_clock_mhz != null || cpu.current_clock_mhz != null |
|
|
|
|
? [cpu.current_clock_mhz != null ? `now ${cpu.current_clock_mhz}` : null, cpu.max_clock_mhz != null ? `max ${cpu.max_clock_mhz}` : null] |
|
|
|
|
.filter(Boolean) |
|
|
|
|
.map((s) => `${s} MHz`) |
|
|
|
|
.join(" · ") |
|
|
|
|
: "—" |
|
|
|
|
)}</td> |
|
|
|
|
<td class="muted">${escapeHtml( |
|
|
|
|
cpu.l2_cache_kb != null || cpu.l3_cache_kb != null |
|
|
|
|
? [`L2 ${fmtKb(cpu.l2_cache_kb)}`, `L3 ${fmtKb(cpu.l3_cache_kb)}`].join(" · ") |
|
|
|
|
: "—" |
|
|
|
|
)}</td> |
|
|
|
|
<td class="muted">${escapeHtml( |
|
|
|
|
cpu.socket_count != null && cpu.socket_count > 1 |
|
|
|
|
? `${cpu.socket || "—"} (${cpu.socket_count}×)` |
|
|
|
|
: cpu.socket || "—" |
|
|
|
|
)}</td> |
|
|
|
|
<td class="muted">${escapeHtml(cpu.architecture || "—")}</td> |
|
|
|
|
</tr>`; |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (snap.gpus?.length) { |
|
|
|
|
html += `<div class="section-title">GPU</div><table><thead><tr><th>Card</th><th>Dedicated memory</th></tr></thead><tbody>`; |
|
|
|
|
function renderGpuSection(snap) { |
|
|
|
|
if (!snap.gpus?.length) { |
|
|
|
|
if (!snap.gpu_model) return ""; |
|
|
|
|
return `<div class="section-title">Display adapter (GPU)</div>
|
|
|
|
|
<table><thead><tr><th>Card name</th><th>Dedicated memory</th></tr></thead><tbody> |
|
|
|
|
<tr><td>${escapeHtml(snap.gpu_model)}</td><td>—</td></tr> |
|
|
|
|
</tbody></table>`; |
|
|
|
|
} |
|
|
|
|
let html = `<div class="section-title">Display adapter (GPU)</div>`; |
|
|
|
|
html += `<table><thead><tr><th>Card</th><th>Vendor</th><th>Dedicated</th><th>Driver</th><th>Resolusi</th><th>Status</th></tr></thead><tbody>`; |
|
|
|
|
html += snap.gpus |
|
|
|
|
.map((g) => { |
|
|
|
|
const mem = |
|
|
|
|
g.dedicated_memory_mb != null ? `${g.dedicated_memory_mb} MB` : "—"; |
|
|
|
|
return `<tr><td>${escapeHtml(g.name)}</td><td>${escapeHtml(mem)}</td></tr>`; |
|
|
|
|
const driver = [g.driver_version, g.driver_date ? fmtDate(g.driver_date) : null] |
|
|
|
|
.filter(Boolean) |
|
|
|
|
.join(" · ") || "—"; |
|
|
|
|
return `<tr>
|
|
|
|
|
<td><strong>${escapeHtml(g.name)}</strong>${g.video_processor ? `<div class="muted small">${escapeHtml(g.video_processor)}</div>` : ""}</td> |
|
|
|
|
<td class="muted">${escapeHtml(g.adapter_compatibility || "—")}</td> |
|
|
|
|
<td>${escapeHtml(mem)}</td> |
|
|
|
|
<td class="muted">${escapeHtml(driver)}</td> |
|
|
|
|
<td class="muted">${escapeHtml(g.current_resolution || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(g.status || "—")}</td> |
|
|
|
|
</tr>`; |
|
|
|
|
}) |
|
|
|
|
.join(""); |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
} |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (snap.disks?.length) { |
|
|
|
|
html += `<div class="section-title">Disk</div><table><thead><tr><th>Drive</th><th>Model</th><th>Used</th><th>Free</th></tr></thead><tbody>`; |
|
|
|
|
function renderPhysicalDiskSection(snap) { |
|
|
|
|
if (!snap.physical_disks?.length) return ""; |
|
|
|
|
let html = `<div class="section-title">Physical disks (${snap.physical_disks.length})</div>`; |
|
|
|
|
html += `<table><thead><tr><th>#</th><th>Model</th><th>Media</th><th>Bus</th><th>Kapasitas</th><th>Partisi</th><th>Status</th><th>Health</th></tr></thead><tbody>`; |
|
|
|
|
html += snap.physical_disks |
|
|
|
|
.map((d) => { |
|
|
|
|
const healthWarn = d.health_status && d.health_status !== "Healthy" ? "style='color:var(--warn)'" : ""; |
|
|
|
|
return `<tr>
|
|
|
|
|
<td>${escapeHtml(d.disk_number ?? d.device_id ?? "—")}</td> |
|
|
|
|
<td><strong>${escapeHtml(d.friendly_name || d.model || "—")}</strong></td> |
|
|
|
|
<td><span class="badge media-${escapeHtml((d.media_type || "unknown").toLowerCase())}">${escapeHtml(d.media_type || "—")}</span></td> |
|
|
|
|
<td class="muted">${escapeHtml(d.bus_type || "—")}</td> |
|
|
|
|
<td>${escapeHtml(d.total_gb != null ? `${d.total_gb} GB` : "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(d.partition_style || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(d.operational_status || "—")}</td> |
|
|
|
|
<td ${healthWarn}>${escapeHtml(d.health_status || "—")}</td> |
|
|
|
|
</tr>`; |
|
|
|
|
}) |
|
|
|
|
.join(""); |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderDiskSection(snap) { |
|
|
|
|
let html = renderPhysicalDiskSection(snap); |
|
|
|
|
if (!snap.disks?.length) return html; |
|
|
|
|
html += `<div class="section-title">Volumes (partisi)</div>`; |
|
|
|
|
html += `<table><thead><tr><th>Drive</th><th>Model</th><th>File system</th><th>Total</th><th>Used</th><th>Free</th></tr></thead><tbody>`; |
|
|
|
|
html += snap.disks |
|
|
|
|
.map((d) => { |
|
|
|
|
const warn = d.pct_used > 85 ? "style='color:var(--warn)'" : ""; |
|
|
|
|
return `<tr><td>${escapeHtml(d.drive_letter)}</td><td class="muted">${escapeHtml(d.model || "—")}</td><td ${warn}>${escapeHtml(d.pct_used)}%</td><td>${escapeHtml(d.free_gb)} GB</td></tr>`; |
|
|
|
|
return `<tr>
|
|
|
|
|
<td>${escapeHtml(d.drive_letter)}</td> |
|
|
|
|
<td>${escapeHtml(d.model || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(d.file_system || "—")}</td> |
|
|
|
|
<td class="muted">${escapeHtml(d.total_gb)} GB</td> |
|
|
|
|
<td ${warn}>${escapeHtml(d.pct_used)}%</td> |
|
|
|
|
<td>${escapeHtml(d.free_gb)} GB</td> |
|
|
|
|
</tr>`; |
|
|
|
|
}) |
|
|
|
|
.join(""); |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
} |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (snap.network?.length) { |
|
|
|
|
html += `<div class="section-title">Network</div><table><thead><tr><th>Adapter</th><th>MAC</th><th>IP</th></tr></thead><tbody>`; |
|
|
|
|
html += snap.network |
|
|
|
|
.map( |
|
|
|
|
(n) => |
|
|
|
|
`<tr><td>${escapeHtml(n.adapter_name)}</td><td class="muted">${escapeHtml(n.mac_address || "—")}</td><td>${escapeHtml(n.ip_address || "—")}</td></tr>` |
|
|
|
|
) |
|
|
|
|
function renderDisplaySection(peripherals) { |
|
|
|
|
const displays = (peripherals || []).filter((p) => p.connection === "display"); |
|
|
|
|
if (!displays.length) return ""; |
|
|
|
|
|
|
|
|
|
let html = `<div class="section-title">Display devices (monitor)</div>`; |
|
|
|
|
html += `<table><thead><tr><th>Monitor</th><th>Manufacturer</th><th>Resolusi</th><th>Refresh</th><th>Tipe</th><th>Serial</th></tr></thead><tbody>`; |
|
|
|
|
html += displays |
|
|
|
|
.map((p) => { |
|
|
|
|
const res = |
|
|
|
|
p.resolution || |
|
|
|
|
(p.resolution_width && p.resolution_height |
|
|
|
|
? `${p.resolution_width}x${p.resolution_height}` |
|
|
|
|
: "—"); |
|
|
|
|
const hz = p.refresh_hz != null ? `${p.refresh_hz} Hz` : "—"; |
|
|
|
|
const kind = |
|
|
|
|
p.is_external === true |
|
|
|
|
? "Eksternal" |
|
|
|
|
: p.is_external === false |
|
|
|
|
? "Internal" |
|
|
|
|
: "—"; |
|
|
|
|
return `<tr>
|
|
|
|
|
<td><strong>${escapeHtml(p.name)}</strong></td> |
|
|
|
|
<td class="muted">${escapeHtml(p.manufacturer || "—")}</td> |
|
|
|
|
<td>${escapeHtml(res)}</td> |
|
|
|
|
<td class="muted">${escapeHtml(hz)}</td> |
|
|
|
|
<td><span class="badge conn-display">${escapeHtml(kind)}</span></td> |
|
|
|
|
<td class="muted">${escapeHtml(p.device_serial || p.edid_product || "—")}</td> |
|
|
|
|
</tr>`; |
|
|
|
|
}) |
|
|
|
|
.join(""); |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
} |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderPeripheralsSection(peripherals) { |
|
|
|
|
const items = (peripherals || []).filter((p) => p.connection !== "display"); |
|
|
|
|
if (!items.length) return ""; |
|
|
|
|
|
|
|
|
|
if (snap.peripherals?.length) { |
|
|
|
|
html += `<div class="section-title">Peripherals (${escapeHtml(snap.peripherals.length)})</div>`; |
|
|
|
|
let html = `<div class="section-title">Peripherals (${escapeHtml(items.length)})</div>`; |
|
|
|
|
html += `<table class="peripherals-table"><thead><tr><th>Nama</th><th>Kelas</th><th>Koneksi</th><th>Role</th><th>Detail</th></tr></thead><tbody>`; |
|
|
|
|
html += snap.peripherals |
|
|
|
|
html += items |
|
|
|
|
.map((p) => { |
|
|
|
|
let detail = "—"; |
|
|
|
|
if (p.connection === "display") { |
|
|
|
|
const res = p.resolution || (p.resolution_width && p.resolution_height |
|
|
|
|
? `${p.resolution_width}x${p.resolution_height}` : ""); |
|
|
|
|
const hz = p.refresh_hz != null ? `${p.refresh_hz} Hz` : ""; |
|
|
|
|
detail = [res, hz, p.device_serial, p.edid_product].filter(Boolean).join(" · ") || "—"; |
|
|
|
|
} else if (p.vid && p.pid) { |
|
|
|
|
if (p.vid && p.pid) { |
|
|
|
|
detail = `${p.vid}:${p.pid}`; |
|
|
|
|
} else if (p.vid || p.pid) { |
|
|
|
|
detail = p.vid || p.pid; |
|
|
|
|
@ -291,7 +635,86 @@ function renderDetail(asset, software) {
@@ -291,7 +635,86 @@ function renderDetail(asset, software) {
|
|
|
|
|
}) |
|
|
|
|
.join(""); |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
return html; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
function renderDetail(asset, software) { |
|
|
|
|
const snap = asset.latest_snapshot; |
|
|
|
|
const formFactor = |
|
|
|
|
snap?.system?.form_factor || asset.metadata?.form_factor || null; |
|
|
|
|
|
|
|
|
|
const tagRow = [ |
|
|
|
|
asset.device_status ? tagBadge(asset.device_status, DEVICE_STATUS_COLORS) : "", |
|
|
|
|
asset.asset_for ? tagBadge(asset.asset_for, ASSET_FOR_COLORS) : "", |
|
|
|
|
asset.asset_on ? tagBadge(asset.asset_on, ASSET_ON_COLORS) : "", |
|
|
|
|
].filter(Boolean).join(" "); |
|
|
|
|
|
|
|
|
|
const ownerHtml = asset.owner_ldap_username |
|
|
|
|
? `<span class="badge af-geonet">${escapeHtml(asset.owner_ldap_username)}</span>` |
|
|
|
|
: `<span class="muted">—</span>`; |
|
|
|
|
|
|
|
|
|
const identity = [ |
|
|
|
|
["Hostname", asset.hostname], |
|
|
|
|
["Display name", asset.display_name], |
|
|
|
|
["Form factor", formFactor], |
|
|
|
|
["Chassis", snap?.system?.chassis_label || asset.metadata?.chassis_label], |
|
|
|
|
["Asset ID", asset.id], |
|
|
|
|
["Serial", asset.serial_number], |
|
|
|
|
["SMBIOS UUID", asset.smbios_uuid], |
|
|
|
|
["Machine ID", asset.machine_id], |
|
|
|
|
["MAC", asset.primary_physical_mac], |
|
|
|
|
["Manufacturer", asset.manufacturer], |
|
|
|
|
["Model", asset.model], |
|
|
|
|
["Snapshots", asset.snapshot_count], |
|
|
|
|
["Last seen", fmtDate(asset.last_seen_at)], |
|
|
|
|
]; |
|
|
|
|
|
|
|
|
|
let html = ``; |
|
|
|
|
|
|
|
|
|
if (tagRow || asset.owner_ldap_username) { |
|
|
|
|
html += `<div class="asset-tags-row">
|
|
|
|
|
${ownerHtml ? `<span class="muted small">Owner:</span> ${ownerHtml}` : ""} |
|
|
|
|
${tagRow ? ` ${tagRow}` : ""} |
|
|
|
|
${asset.notes ? `<div class="muted small notes-preview">${escapeHtml(asset.notes)}</div>` : ""} |
|
|
|
|
</div>`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
html += `<dl class="detail-grid">${identity |
|
|
|
|
.map( |
|
|
|
|
([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v || "—")}</dd>` |
|
|
|
|
) |
|
|
|
|
.join("")}</dl>`; |
|
|
|
|
|
|
|
|
|
html += renderEditPanel(asset); |
|
|
|
|
|
|
|
|
|
if (snap) { |
|
|
|
|
html += `<div class="section-title">Snapshot terbaru — ${escapeHtml(fmtDate(snap.collected_at))}</div>`; |
|
|
|
|
html += `<dl class="detail-grid">
|
|
|
|
|
<dt>OS</dt><dd>${escapeHtml(snap.os_name || "—")} ${escapeHtml(snap.os_version || "")}</dd> |
|
|
|
|
<dt>CPU</dt><dd>${escapeHtml(snap.cpu?.model || snap.cpu_model || "—")}</dd> |
|
|
|
|
<dt>RAM</dt><dd>${escapeHtml(snap.ram_gb != null ? `${snap.ram_gb} GB` : "—")}</dd> |
|
|
|
|
<dt>BIOS</dt><dd>${escapeHtml(snap.bios_version || "—")}</dd> |
|
|
|
|
<dt>Agent</dt><dd>${escapeHtml(snap.agent_version)}</dd> |
|
|
|
|
</dl>`; |
|
|
|
|
|
|
|
|
|
html += renderCpuSection(snap); |
|
|
|
|
html += renderMemorySection(snap); |
|
|
|
|
html += renderGpuSection(snap); |
|
|
|
|
html += renderDiskSection(snap); |
|
|
|
|
html += renderDisplaySection(snap.peripherals); |
|
|
|
|
|
|
|
|
|
if (snap.network?.length) { |
|
|
|
|
html += `<div class="section-title">Network</div><table><thead><tr><th>Adapter</th><th>MAC</th><th>IP</th></tr></thead><tbody>`; |
|
|
|
|
html += snap.network |
|
|
|
|
.map( |
|
|
|
|
(n) => |
|
|
|
|
`<tr><td>${escapeHtml(n.adapter_name)}</td><td class="muted">${escapeHtml(n.mac_address || "—")}</td><td>${escapeHtml(n.ip_address || "—")}</td></tr>` |
|
|
|
|
) |
|
|
|
|
.join(""); |
|
|
|
|
html += `</tbody></table>`; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
html += renderPeripheralsSection(snap.peripherals); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (software?.items?.length) { |
|
|
|
|
@ -308,8 +731,10 @@ function renderDetail(asset, software) {
@@ -308,8 +731,10 @@ function renderDetail(asset, software) {
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
el.detailTitle.textContent = asset.hostname; |
|
|
|
|
const titleBadges = [formFactor ? formFactorBadge(formFactor) : ""].filter(Boolean).join(" "); |
|
|
|
|
el.detailTitle.innerHTML = `${escapeHtml(asset.display_name || asset.hostname)}${titleBadges ? " " + titleBadges : ""}`; |
|
|
|
|
el.detailContent.innerHTML = html; |
|
|
|
|
bindEditPanel(asset); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
async function loadStats() { |
|
|
|
|
@ -339,10 +764,16 @@ async function openDetail(assetId) {
@@ -339,10 +764,16 @@ async function openDetail(assetId) {
|
|
|
|
|
api(`/api/v1/assets/${assetId}/software?limit=50`), |
|
|
|
|
]); |
|
|
|
|
el.detailContent.className = "detail-body"; |
|
|
|
|
try { |
|
|
|
|
renderDetail(asset, software); |
|
|
|
|
} catch (renderErr) { |
|
|
|
|
el.detailContent.className = "detail-body error"; |
|
|
|
|
el.detailContent.textContent = `Gagal render detail: ${renderErr.message}`; |
|
|
|
|
console.error(renderErr); |
|
|
|
|
} |
|
|
|
|
} catch (err) { |
|
|
|
|
el.detailContent.className = "detail-body error"; |
|
|
|
|
el.detailContent.textContent = err.message; |
|
|
|
|
el.detailContent.innerHTML = `<p><strong>Gagal memuat detail asset</strong></p><p>${escapeHtml(err.message)}</p>`; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
@ -367,7 +798,7 @@ async function connect() {
@@ -367,7 +798,7 @@ async function connect() {
|
|
|
|
|
showConnectedUi(true); |
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
await Promise.all([loadStats(), loadAssets()]); |
|
|
|
|
await Promise.all([loadStats(), loadAssets(), loadOptions()]); |
|
|
|
|
} catch (err) { |
|
|
|
|
showConnectedUi(false); |
|
|
|
|
showError(err); |
|
|
|
|
|