"use strict"; // chj.disks UI. All server data is rendered as text (never as HTML). Every change is queued, validated and // simulated by Core (plan), reviewed by the user and only then started as a remote job (apply). const api = window.chjPlugin; const i18n = window.chjDisksI18n.createI18n("en"); const t = (key, values) => i18n.t(key, values); let endedSessionId = ""; let sessionsLoading = false; // Guard every continuation, including sudo retries and dialog answers, against a server switch. async function sessionResult(promise, token) { const value = await promise; if (token !== state.generation) throw new Error("CHJ_SESSION_CHANGED"); return value; } const el = new Proxy({}, { get: (_target, id) => document.getElementById(id) }); const MiB = 1048576; const state = { generation: 0, sessionId: "", identity: "", sessions: [], inventory: null, selectedDisk: null, selected: null, queue: [], preview: null, busy: false, job: null, jobTimer: null, sudo: null }; // ---------- helpers ---------- function h(tag, props = {}, children = []) { const node = document.createElement(tag); for (const [key, value] of Object.entries(props)) { if (value === undefined || value === null || value === false) continue; if (key === "className") node.className = value; else if (key === "text") node.textContent = String(value); else if (key === "style") Object.assign(node.style, value); else if (key === "dataset") Object.assign(node.dataset, value); else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value); else if (typeof value === "boolean") node[key] = value; else node.setAttribute(key, String(value)); } for (const child of [].concat(children)) if (child !== null && child !== undefined && child !== false) node.append(child instanceof Node ? child : String(child)); return node; } const locale = () => (globalThis.__CHJ_I18N__?.manifest?.languages || []).find((entry) => entry.code === i18n.getLanguage())?.locale || ({ cs: "cs-CZ", de: "de-DE" }[i18n.getLanguage()] || "en-GB"); const known = (value) => value !== null && value !== undefined && Number.isFinite(Number(value)); function bytes(value) { if (!known(value)) return "—"; const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; let amount = Number(value); let unit = 0; while (Math.abs(amount) >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; } return `${amount.toLocaleString(locale(), { maximumFractionDigits: unit >= 3 ? 2 : unit ? 1 : 0 })} ${units[unit]}`; } const pct = (used, total) => known(used) && known(total) && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null; function setStatus(message, kind = "") { el.status.textContent = message || ""; el.status.className = `status ${kind}`; } function parseError(error) { const raw = String(error?.message || error || ""); const match = raw.match(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+): ([\s\S]*)$/); return match ? { code: match[1], detail: match[2].trim() } : { code: "UNKNOWN", detail: raw.replace(/^Error invoking remote method '[^']+': (?:\w*Error: )?/, "") }; } const ERROR_KEYS = { SUDO_NOT_PERMITTED: "The SSH account has no sudo rights; disks are shown read-only.", SUDO_PASSWORD_INCORRECT: "The sudo password was rejected.", SUDO_TTY_REQUIRED: "The sudo policy requires a terminal (requiretty); CH-J does not bypass it.", SUDO_UNAVAILABLE: "sudo is not installed and the SSH account is not root.", SESSION_NOT_CONNECTED: "The SSH connection was lost. A running disk job continues on the server; its status is checked again after reconnecting.", REMOTE_COMMAND_TIMEOUT: "The server did not respond in time.", DISKS_PLAN_STALE: "The disk layout changed since the plan was reviewed. Nothing was changed; review the new plan.", DISKS_PLAN_EXPIRED: "The plan expired or was already used. Nothing was changed.", DISKS_LAYOUT_CHANGED: "The disk layout changed since planning; the job stopped before changing anything.", DISKS_BUSY: "Another disk job is running on this server.", DISKS_JOB_EXISTS: "This job was already submitted; its status is shown instead.", DISKS_PROTECTED_DEVICE: "This device belongs to Proxmox host or guest storage; destructive changes are not offered.", DISKS_DEVICE_IN_USE: "The device is in use (mounted, active swap or used by another layer). CH-J never releases it automatically.", DISKS_SHRINK_REQUIRES_UNMOUNT: "Shrinking needs an unmounted file system.", DISKS_XFS_SHRINK_UNSUPPORTED: "XFS cannot be shrunk with the standard tools. Create a new, smaller file system and migrate the data.", DISKS_XFS_GROW_NEEDS_MOUNT: "XFS can only be grown while mounted.", DISKS_FAT_RESIZE_UNSUPPORTED: "Resizing FAT is not implemented.", DISKS_LUKS_PASSPHRASE_REQUIRED: "Resizing this LUKS mapping needs its passphrase; CH-J never handles LUKS secrets.", DISKS_LUKS_SHRINK_UNSUPPORTED: "LUKS is never shrunk automatically.", DISKS_TOOL_MISSING: "A required tool is not installed on the server. CH-J does not install packages.", DISKS_SIZE_TOO_SMALL: "The size is below the safe minimum.", DISKS_NO_FREE_SPACE: "There is no unallocated space directly after the partition.", DISKS_OPERATION_DISABLED: "This operation is prepared but not enabled yet.", DISKS_REBOOT_REQUIRED: "The kernel still uses the old partition table. Reboot (or finish in a rescue system) before continuing.", DISKS_PRIVILEGE_REQUIRED: "This needs root or sudo.", DISKS_PLATFORM_UNSUPPORTED: "Disk management is available only for Linux servers.", DISKS_SYSTEM_MOUNT: "This mount point is needed by the running system; CH-J never unmounts it.", DISKS_LV_THIN_UNSUPPORTED: "Thin pools and thin volumes are shown read-only.", DISKS_SUGGESTION_GONE: "The expansion is no longer possible with the current layout.", DISKS_SYSTEM_PARTITION: "Boot partition of the disk that runs the system." }; function describe(error) { const { code, detail } = parseError(error); const key = ERROR_KEYS[code]; return key ? `${t(key)}${detail && !detail.startsWith(code) ? ` (${detail})` : ""}` : detail || code; } const errorCode = (error) => parseError(error).code; // ---------- sudo ---------- function askSudo(message) { return new Promise((resolve) => { el.sudoMessage.textContent = message || t("Reading partition tables, LVM and LUKS details and all changes need root rights."); el.sudoPassword.value = ""; el.sudoError.hidden = true; const finish = (value) => { el.sudoForm.removeEventListener("submit", submit); el.sudoDialog.removeEventListener("close", cancel); if (el.sudoDialog.open) el.sudoDialog.close(); resolve(value); }; const submit = (event) => { event.preventDefault(); const password = el.sudoPassword.value; el.sudoPassword.value = ""; finish({ sudoPassword: password, rememberSudo: el.sudoRemember.checked }); }; const cancel = () => finish(null); el.sudoForm.addEventListener("submit", submit); el.sudoDialog.addEventListener("close", cancel); el.sudoDialog.showModal(); el.sudoPassword.focus(); }); } // Runs fn with no password first (root, NOPASSWD or a remembered password); asks only when sudo requires it. async function withSudo(fn, message) { const requestGeneration = state.generation; try { try { return await sessionResult(fn({}), requestGeneration); } catch (error) { if (requestGeneration !== state.generation) return; const code = errorCode(error); if (!["SUDO_PASSWORD_REQUIRED", "SUDO_PASSWORD_INCORRECT"].includes(code)) throw error; for (let attempt = 0; attempt < 3; attempt += 1) { const credentials = await sessionResult(askSudo(attempt ? t("The sudo password was rejected.") : message), requestGeneration); if (!credentials) throw new Error("CHJ_CANCELLED: cancelled"); try { return await sessionResult(fn(credentials), requestGeneration); } catch (retry) { if (requestGeneration !== state.generation) return; if (errorCode(retry) !== "SUDO_PASSWORD_INCORRECT") throw retry; } } throw new Error("SUDO_PASSWORD_INCORRECT: rejected"); } } catch (error) { if (requestGeneration !== state.generation) return; throw error; } } const cancelled = (error) => errorCode(error) === "CHJ_CANCELLED"; // ---------- sessions ---------- async function loadSessions() { if (sessionsLoading) return; sessionsLoading = true; let sessions; try { sessions = (await api.sessions.list()).filter((session) => session.state === "connected"); } catch (error) { setStatus(t("Sessions cannot be loaded: {error}", { error: describe(error) }), "error"); return; } finally { sessionsLoading = false; } state.sessions = sessions; const next = api.sessions.reconcile(endedSessionId || state.sessionId, sessions); if (next.reason === "gone") endedSessionId ||= state.sessionId; const current = sessions.find((session) => session.sessionId === next.id); const identity = current ? `${current.profileId}|${current.host}|${current.port}|${current.username}` : ""; el.sessionSelect.replaceChildren(new Option(sessions.length ? t("Select a session…") : t("No connected session"), "")); for (const session of sessions) el.sessionSelect.add(new Option(api.sessions.displayName(session), session.sessionId)); el.sessionSelect.value = next.id; if (next.reason === "choose") api.sessions.offerChoice(el.sessionSelect, sessions, t("Select a session…")); if (next.id !== state.sessionId || identity !== state.identity) { resetServer(); Object.assign(state, { identity, sessionId: next.id, disconnected: false }); if (current) await loadInventory(); } if (next.reason === "gone") setStatus(t("Session ended. Choose a server."), "warn"); } // Data of one server is never shown as the state of another one. function resetServer() { state.generation += 1; stopJobPolling(); for (const dialog of document.querySelectorAll("dialog[open]")) dialog.close(); el.sudoPassword.value = ""; state.busy = false; state.sudo = null; closeContextMenu(); el.detailPanel.hidden = true; if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {}); Object.assign(state, { inventory: null, selectedDisk: null, selected: null, queue: [], preview: null, job: null }); el.jobPanel.hidden = true; render(); } // ---------- inventory ---------- async function loadInventory({ privileged = false } = {}) { const requestGeneration = state.generation; const sessionId = state.sessionId; if (!sessionId) return; const token = state.generation; state.busy = true; setStatus(t("Reading disks…"), "busy"); updateButtons(); try { const call = (credentials) => api.disks.inspect(sessionId, { privileged, ...credentials }); const inventory = privileged ? await sessionResult(withSudo(call), requestGeneration) : await sessionResult(call({}), requestGeneration); if (token !== state.generation) return; state.inventory = inventory; if (!inventory.supported) { render(); setStatus(""); return; } if (!inventory.disks.some((disk) => disk.name === state.selectedDisk)) state.selectedDisk = inventory.disks.find((disk) => disk.system)?.name || inventory.disks[0]?.name || null; state.selected = null; setStatus(inventory.privileged ? t("Inventory loaded {time}.", { time: new Date(inventory.collectedAt).toLocaleTimeString(locale()) }) : t("Read-only inventory without root: partition tables, LVM and LUKS details are partial. Use “Load with sudo” for full details and changes."), inventory.privileged ? "" : "warn"); render(); if (inventory.privileged && !state.job) checkJobs(true).catch(() => {}); } catch (error) { if (requestGeneration !== state.generation) return; if (token !== state.generation) return; if (!cancelled(error)) setStatus(t("Disks cannot be read: {error}", { error: describe(error) }), "error"); } finally { if (requestGeneration === state.generation) { if (token === state.generation) { state.busy = false; updateButtons(); renderSuggestions(); }} } } function currentDisk() { return state.inventory?.disks?.find((disk) => disk.name === state.selectedDisk) || null; } function nodeByPath(path) { return state.inventory?.nodes?.find((node) => node.path === path || node.displayPath === path || `/dev/${node.kname}` === path) || null; } function diskByPath(path) { return state.inventory?.disks?.find((disk) => disk.path === path) || null; } // View of a disk: the inventory, or the simulated result of the queued operations (preview). function diskView(disk) { const preview = state.preview?.after?.find((entry) => entry.name === disk.name); const segments = (preview?.segments || disk.segments).map((segment) => { if (segment.kind !== "partition") return { ...segment }; const part = disk.partitions.find((entry) => entry.number === segment.number && entry.start === segment.start && !segment.created); return { ...segment, part: part || null, fstype: segment.fstype ?? part?.fstype ?? null, label: segment.label ?? part?.label ?? null, path: segment.path || part?.path || null, mountpoints: segment.mountpoints || part?.mountpoints || [] }; }); return { disk, segments, preview: Boolean(preview), table: preview ? preview.table : disk.table }; } // ---------- rendering ---------- const FS_CLASS = { ext2: "ext", ext3: "ext", ext4: "ext4", xfs: "xfs", btrfs: "btrfs", vfat: "fat", ntfs: "ntfs", swap: "swap", LVM2_member: "lvm", crypto_LUKS: "luks", free: "free", zfs_member: "zfs", linux_raid_member: "raid" }; const modelText = (disk) => (disk.model && disk.vendor && disk.model.startsWith(disk.vendor) ? disk.model : [disk.vendor, disk.model].filter(Boolean).join(" ")) || "—"; const LEGEND = [["ext4", "EXT4"], ["xfs", "XFS"], ["btrfs", "Btrfs"], ["fat", "FAT32"], ["ntfs", "NTFS"], ["swap", "Linux swap"], ["lvm", "LVM"], ["luks", "LUKS"], ["free", "Unallocated"], ["unknown", "Unknown"]]; function fsClass(fstype, kind) { return kind === "free" ? "free" : FS_CLASS[fstype] || (fstype ? "unknown" : "none"); } function fsName(fstype) { return { crypto_LUKS: "LUKS", LVM2_member: "LVM PV", linux_raid_member: "RAID", zfs_member: "ZFS", vfat: "FAT", swap: "swap" }[fstype] || fstype || t("unformatted"); } function mediaText(disk) { return { hdd: "HDD", ssd: "SSD", nvme: "NVMe", virtual: t("virtual disk"), loop: t("loop device (image file)"), unknown: t("unknown") }[disk.mediaType] || disk.mediaType; } function render() { const inventory = state.inventory; el.unsupported.hidden = !inventory || inventory.supported; if (inventory && !inventory.supported) { el.unsupported.replaceChildren(h("strong", { text: t("Unsupported platform") }), h("p", { text: t("This server runs {os}. chj.disks manages Linux block devices only; no Linux disk tools were started on it and nothing was changed.", { os: inventory.os || inventory.platform }) })); } el.workspace.hidden = !inventory?.supported; el.privilegeBadge.textContent = !inventory?.supported ? "" : inventory.privileged ? t("full inventory (root)") : t("read-only, partial"); el.privilegeBadge.className = `pill ${inventory?.privileged ? "ok" : "warn"}`; renderSuggestions(); if (!inventory?.supported) { renderQueue(); updateButtons(); return; } renderDiskList(); renderDisk(); renderLvm(); renderQueue(); updateButtons(); } function renderDiskList() { el.diskList.replaceChildren(...state.inventory.disks.map((disk) => h("li", { role: "option", "aria-selected": String(disk.name === state.selectedDisk), tabindex: 0, className: disk.name === state.selectedDisk ? "active" : "", onclick: () => { state.selectedDisk = disk.name; state.selected = null; render(); }, onkeydown: (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); state.selectedDisk = disk.name; render(); } } }, [ h("strong", { text: disk.path }), h("span", { text: `${bytes(disk.size)} · ${mediaText(disk)}` }), h("small", { text: modelText(disk) }), h("span", { className: "badges" }, [disk.system && h("i", { className: "pill warn", text: t("system") }), disk.protected && h("i", { className: "pill danger", text: t("protected") }), disk.layers?.lvm && h("i", { className: "pill", text: "LVM" }), disk.layers?.luks && h("i", { className: "pill", text: "LUKS" }), disk.layers?.raid && h("i", { className: "pill", text: "RAID" }), disk.layers?.zfs && h("i", { className: "pill", text: "ZFS" })]) ]))); if (!state.inventory.disks.length) el.diskList.append(h("li", { className: "empty", text: t("No block devices found.") })); } function fact(label, value) { return h("div", {}, [h("dt", { text: label }), h("dd", { text: value ?? "—" })]); } function renderDisk() { const disk = currentDisk(); el.diskFacts.replaceChildren(); el.diskMap.replaceChildren(); el.partitionRows.replaceChildren(); el.diskBadges.replaceChildren(); if (!disk) { el.diskTitle.textContent = "—"; return; } const view = diskView(disk); el.diskTitle.textContent = `${disk.path} — ${bytes(disk.size)}`; el.previewBadge.hidden = !view.preview; el.diskBadges.replaceChildren(...[ h("i", { className: "pill", text: mediaText(disk) }), disk.transport && h("i", { className: "pill", text: disk.transport }), disk.system && h("i", { className: "pill warn", text: t("contains the running system") }), disk.protected && h("i", { className: "pill danger", text: t("Proxmox host storage — protected") }), disk.readOnly && h("i", { className: "pill danger", text: t("read-only") }) ].filter(Boolean)); const table = disk.table; el.diskFacts.append(...[ fact(t("Model"), modelText(disk)), fact(t("Serial / WWN"), [disk.serial, disk.wwid].filter(Boolean).join(" · ") || "—"), fact(t("Partition table"), table ? `${table.type === "dos" ? "MBR (dos)" : table.type.toUpperCase()}${table.source === "sysfs" ? ` (${t("partial, without root")})` : ""}` : t("none")), fact(t("Sectors"), `${t("logical")} ${disk.logicalSectorSize} B / ${t("physical")} ${disk.physicalSectorSize} B · ${disk.sectors.toLocaleString(locale())}`), fact(t("Layers"), Object.entries(disk.layers || {}).filter(([, on]) => on).map(([name]) => name.toUpperCase()).join(", ") || t("none")), disk.loopBacking && fact(t("Image file"), disk.loopBacking), table?.gptBackupMisplaced && fact(t("GPT"), t("The backup GPT header is not at the end of the disk (the disk was enlarged). It is moved automatically before the new space is used.")), table?.mbrLimited && fact(t("MBR"), t("MBR can address only the first 2 TiB of this disk.")) ].filter(Boolean)); renderMap(view); renderRows(view); } function selectSegment(segment) { state.selected = segment.kind === "free" ? { kind: "free", start: segment.start } : segment.kind === "whole" ? { kind: "whole" } : { kind: "partition", number: segment.number }; renderDisk(); showDetails(segment); } function isSelected(segment) { const selected = state.selected; return Boolean(selected && selected.kind === (segment.kind === "partition" ? "partition" : segment.kind) && (segment.kind === "partition" ? selected.number === segment.number : segment.kind === "free" ? selected.start === segment.start : true)); } function renderMap(view) { const disk = view.disk; let segments = view.segments; if (!view.table) { const node = nodeByPath(disk.path); segments = [{ kind: "whole", start: 0, size: disk.sectors, bytes: disk.size, fstype: node?.fstype || null, label: node?.label || null, path: disk.path, mountpoints: node?.mountpoints || [], node }]; } const total = segments.reduce((sum, segment) => sum + segment.bytes, 0) || 1; for (const segment of segments) { const part = segment.part; const usage = part?.usage || segment.node?.usage || null; const used = usage ? pct(usage.used, usage.size) : null; const cls = fsClass(segment.fstype, segment.kind); const block = h("div", { className: `segment fs-${cls}${isSelected(segment) ? " selected" : ""}${segment.created ? " created" : ""}`, style: { flexGrow: String(segment.bytes / total * 1000) }, tabindex: 0, role: "button", title: `${segment.kind === "free" ? t("Unallocated") : segment.path || ""} · ${fsName(segment.fstype)} · ${bytes(segment.bytes)}`, onclick: () => selectSegment(segment), oncontextmenu: (event) => { event.preventDefault(); selectSegment(segment); openContextMenu(event.clientX, event.clientY, segment, view); }, onkeydown: (event) => { if (event.key === "Enter") selectSegment(segment); if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { event.preventDefault(); const box = event.currentTarget.getBoundingClientRect(); selectSegment(segment); openContextMenu(box.left + 10, box.bottom - 4, segment, view); } } }, [ used !== null && h("div", { className: "used", style: { width: `${used}%` } }), h("span", { className: "seg-text" }, [h("b", { text: segment.kind === "free" ? t("unallocated") : (segment.path || "").replace("/dev/", "") }), h("small", { text: `${segment.kind === "free" ? "" : `${fsName(segment.fstype)} · `}${bytes(segment.bytes)}` })]) ]); if (segment.kind === "partition" && isSelected(segment) && !view.preview) attachGrip(block, segment, view, total); el.diskMap.append(block); } el.legend.replaceChildren(...LEGEND.map(([cls, name]) => h("li", {}, [h("b", { className: `fs-${cls}` }), name === "Unallocated" || name === "Unknown" ? t(name) : name]))); } // Drag the right boundary of the selected partition; the exact value is confirmed in the resize dialog. function attachGrip(block, segment, view, total) { const limits = resizeLimits(view, segment); if (!limits.allowed) return; const grip = h("span", { className: "grip", title: t("Drag to resize"), "aria-hidden": "true" }); grip.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopPropagation(); const mapBox = el.diskMap.getBoundingClientRect(); const box = block.getBoundingClientRect(); const bytesPerPixel = total / mapBox.width; const ghost = h("div", { className: "drag-ghost" }); el.diskMap.append(ghost); let size = segment.size; const move = (moveEvent) => { const right = Math.max(box.left + 4, Math.min(moveEvent.clientX, mapBox.right)); const newBytes = (right - box.left) * bytesPerPixel; size = Math.max(limits.minSectors, Math.min(limits.maxSectors, Math.round(newBytes / view.disk.logicalSectorSize))); Object.assign(ghost.style, { left: `${box.left - mapBox.left}px`, width: `${size * view.disk.logicalSectorSize / bytesPerPixel}px` }); ghost.textContent = bytes(size * view.disk.logicalSectorSize); }; const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); ghost.remove(); if (size !== segment.size) openResize(segment, view, size); }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); }); block.append(grip); } function flagsOf(part) { if (!part) return []; const flags = []; if (part.bootable) flags.push("boot"); if (/LegacyBIOSBootable/.test(part.attrs || "")) flags.push("legacy_boot"); if (["esp", "bios-boot", "lvm", "raid", "swap", "linux-root-x86-64", "linux-extended-boot", "basic-data", "microsoft-reserved", "windows-recovery"].includes(part.typeName)) flags.push(part.typeName); return flags; } function statusText(part, node) { if (!part && !node) return ""; const reasons = (part?.inUseReasons || node?.inUseReasons || []).map((entry) => ({ mounted: t("mounted"), swap: t("active swap"), holders: t("used by {detail}", { detail: entry.detail }), zfs: t("ZFS pool {detail}", { detail: entry.detail }), "raid-member": t("RAID member") }[entry.reason] || entry.reason)); return reasons.join("; ") || t("not in use"); } function renderRows(view) { const disk = view.disk; const rows = []; const pushNodeTree = (kname, depth) => { const node = state.inventory.nodes.find((entry) => entry.kname === kname); if (!node) return; const name = node.displayPath || node.path; const kind = node.kind === "crypt" ? `LUKS${node.crypt?.type ? ` (${node.crypt.type})` : ""}` : node.kind === "lvm" ? `LV${node.lvm?.thinVolume ? " thin" : node.lvm?.thinPool ? " thin pool" : ""}` : node.kind; rows.push(h("tr", { className: "layer", onclick: () => showNodeDetails(node) }, [ h("td", { style: { paddingLeft: `${8 + depth * 18}px` } }, [h("span", { className: "tree", text: "↳ " }), name]), h("td", {}, [h("i", { className: `swatch fs-${fsClass(node.fstype)}` }), `${fsName(node.fstype)} · ${kind}`]), h("td", { text: node.mountpoints.join(", ") || (node.swapActive ? "[SWAP]" : "") }), h("td", { text: node.label || "" }), h("td", { text: bytes(node.size) }), h("td", { text: node.usage ? bytes(node.usage.used) : "—" }), h("td", { text: node.usage ? bytes(node.usage.size - node.usage.used) : "—" }), h("td", { text: "" }), h("td", { text: statusText(null, node) }) ])); for (const child of node.children || []) pushNodeTree(child, depth + 1); }; const segments = view.table ? view.segments : []; for (const segment of segments) { const part = segment.part; const usage = part?.usage; rows.push(h("tr", { className: isSelected(segment) ? "selected" : "", onclick: () => selectSegment(segment), oncontextmenu: (event) => { event.preventDefault(); selectSegment(segment); openContextMenu(event.clientX, event.clientY, segment, view); } }, [ h("td", { text: segment.kind === "free" ? t("unallocated") : `${segment.path || `#${segment.number}`}${segment.created ? ` (${t("new")})` : ""}` }), h("td", {}, [h("i", { className: `swatch fs-${fsClass(segment.fstype, segment.kind)}` }), segment.kind === "free" ? "" : fsName(segment.fstype)]), h("td", { text: segment.mountpoints?.join(", ") || "" }), h("td", { text: [segment.label, part?.name ? `«${part.name}»` : ""].filter(Boolean).join(" ") }), h("td", { text: bytes(segment.bytes) }), h("td", { text: usage ? bytes(usage.used) : "—" }), h("td", { text: usage ? bytes(usage.size - usage.used) : "—" }), h("td", { text: flagsOf(part).join(", ") }), h("td", { text: segment.kind === "free" ? "" : statusText(part) }) ])); if (part?.kernel && !view.preview) for (const child of part.children || []) pushNodeTree(child, 1); } if (!view.table) { const node = nodeByPath(disk.path); if (node) { rows.push(h("tr", {}, [h("td", { text: disk.path }), h("td", { text: fsName(node.fstype) }), h("td", { text: node.mountpoints.join(", ") }), h("td", { text: node.label || "" }), h("td", { text: bytes(disk.size) }), h("td", { text: node.usage ? bytes(node.usage.used) : "—" }), h("td", { text: node.usage ? bytes(node.usage.size - node.usage.used) : "—" }), h("td", { text: "" }), h("td", { text: statusText(null, node) })])); for (const child of node.children || []) pushNodeTree(child, 1); } } el.partitionRows.replaceChildren(...rows); } function renderLvm() { const lvm = state.inventory.lvm; el.lvmPanel.hidden = !lvm?.vgs?.length; if (el.lvmPanel.hidden) return; const rows = []; for (const vg of lvm.vgs) { const pvs = lvm.pvs.filter((pv) => pv.vg === vg.name).map((pv) => pv.path).join(", "); rows.push(h("tr", { className: "vg" }, [h("td", {}, [h("strong", { text: vg.name }), h("small", { text: ` · PV: ${pvs}` })]), h("td", { text: bytes(vg.size) }), h("td", { text: bytes(vg.free) }), h("td", { text: "VG" }), h("td", { text: "" }), h("td", { text: "" })])); for (const lv of lvm.lvs.filter((entry) => entry.vg === vg.name)) { const node = nodeByPath(lv.dmPath) || nodeByPath(lv.path); const type = lv.thinPool ? t("thin pool") : lv.thinVolume ? t("thin volume (pool {pool})", { pool: lv.pool }) : lv.snapshot ? t("snapshot") : lv.segtype || "linear"; const extend = h("button", { className: "mini", type: "button", text: t("Extend…"), disabled: !(vg.free > 0) || lv.thinPool || lv.thinVolume || lv.snapshot || Boolean(lv.pveGuest) || state.inventory.disks.some((disk) => disk.protected && disk.layers?.lvm), title: lv.thinPool || lv.thinVolume ? t("Thin pools and thin volumes are shown read-only.") : "", onclick: () => openLvExtend(lv, vg) }); const reduce = h("button", { className: "mini danger", type: "button", text: t("Reduce…"), disabled: !node || !/^ext[234]$/.test(node.fstype || "") || node.inUse || lv.thinPool || lv.thinVolume || Boolean(lv.pveGuest), title: node?.fstype === "xfs" ? t("XFS cannot be shrunk with the standard tools. Create a new, smaller file system and migrate the data.") : t("Advanced: only an unmounted ext file system."), onclick: () => openLvReduce(lv, vg, node) }); rows.push(h("tr", { onclick: (event) => { if (event.target.tagName !== "BUTTON" && node) showNodeDetails(node); } }, [ h("td", { style: { paddingLeft: "24px" }, text: `${lv.name}${lv.pveGuest ? ` (${t("Proxmox guest {id}", { id: lv.pveGuest })})` : ""}` }), h("td", { text: bytes(lv.size) }), h("td", { text: lv.thinPool && known(lv.dataPercent) ? t("{value} % data used", { value: lv.dataPercent }) : lv.thinVolume && known(lv.dataPercent) ? t("{value} % allocated", { value: lv.dataPercent }) : "" }), h("td", { text: type }), h("td", { text: node ? `${fsName(node.fstype)} ${node.mountpoints.join(", ")}` : lv.active ? "" : t("inactive") }), h("td", { className: "actions-cell" }, [extend, reduce]) ])); } } el.lvmRows.replaceChildren(...rows); } function renderSuggestions() { const list = state.inventory?.supported ? state.inventory.suggestions || [] : []; el.suggestions.replaceChildren(...list.map((suggestion) => { const head = suggestion.kind === "partition" ? t("Found {free} of unallocated space. Partition {target} can be expanded.", { free: bytes(suggestion.freeBytes), target: suggestion.target }) : suggestion.kind === "filesystem" ? t("The file system on {target} is {free} smaller than its device.", { free: bytes(suggestion.freeBytes), target: suggestion.target }) : suggestion.kind === "pv" ? t("The LVM physical volume {target} can grow by {free}.", { free: bytes(suggestion.freeBytes), target: suggestion.target }) : t("Volume group free space ({free}) can extend {target}.", { free: bytes(suggestion.freeBytes), target: suggestion.target }); return h("article", { className: `suggestion ${suggestion.available ? "ok" : "blocked"}` }, [ h("div", {}, [h("strong", { text: head }), h("p", { className: "hint", text: suggestion.available ? t("Steps: {steps}", { steps: suggestion.steps.filter((name) => !["verify-disk", "verify-size", "backup-table", "verify-part"].includes(name)).join(" → ") }) + (suggestion.mayRequireReboot ? ` ${t("If the running kernel does not accept the new partition size, the job stops and asks for a reboot.")}` : "") : t("Automatic expansion is not possible: {reason}", { reason: describe(new Error(`${suggestion.blocked?.code}: ${suggestion.blocked?.message}`)) }) })]), suggestion.available && h("button", { className: "button primary", type: "button", text: t("Expand automatically"), disabled: state.busy || Boolean(state.job && state.job.outcome === "running"), onclick: () => autoExpand(suggestion) }) ]); })); } function renderQueue() { el.queuePanel.hidden = !state.queue.length; el.queueCount.textContent = String(state.queue.length); el.queueList.replaceChildren(...state.queue.map((op) => h("li", { text: describeOperation(op) }))); } function updateButtons() { const ready = Boolean(state.sessionId) && !state.busy && !state.disconnected; el.refreshButton.disabled = !ready; el.privilegedButton.disabled = !ready || !state.inventory?.supported || state.inventory?.privileged || state.inventory?.privilege?.mode === "none"; el.privilegedButton.hidden = Boolean(state.inventory?.privileged); el.jobsButton.disabled = !ready || !state.inventory?.supported || state.inventory?.privilege?.mode === "none"; el.smartButton.disabled = !ready || !currentDisk() || currentDisk()?.kind === "loop"; el.tableButton.disabled = !ready || !manageable() || !currentDisk() || Boolean(currentDisk()?.protected); el.queueApply.disabled = !ready || !state.queue.length || Boolean(state.job && state.job.outcome === "running"); } const manageable = () => Boolean(state.inventory?.supported && state.inventory.privilege?.mode !== "none"); // ---------- details ---------- function showDetails(segment) { const disk = currentDisk(); if (!disk) return; const part = segment.part; if (segment.kind === "free") { showFacts(t("Unallocated space"), [[t("Start sector"), segment.start], [t("End sector"), segment.end], [t("Size"), bytes(segment.bytes)], segment.needsGptRelocation && [t("Note"), t("The backup GPT header is not at the end of the disk (the disk was enlarged). It is moved automatically before the new space is used.")]]); return; } if (segment.kind === "whole") { const node = segment.node; if (node) showNodeDetails(node); return; } if (!part) { showFacts(segment.path || "", [[t("Size"), bytes(segment.bytes)], [t("Note"), t("Partition planned in the queue.")]]); return; } const node = state.inventory.nodes.find((entry) => entry.kname === part.kernel); showFacts(part.path || `#${part.number}`, [ [t("Partition number"), part.number], [t("Start sector"), part.start], [t("End sector"), part.end], [t("Size"), `${bytes(part.bytes)} (${part.size.toLocaleString(locale())} × ${disk.logicalSectorSize} B)`], [t("Partition type"), `${part.typeName || "—"} (${part.type || "—"})`], [t("PARTUUID"), part.uuid], [t("Partition name"), part.name], [t("Flags"), flagsOf(part).join(", ") || "—"], part.attrs && [t("GPT attributes"), part.attrs], [t("File system"), fsName(part.fstype)], [t("File system UUID"), part.fsUuid], [t("Label"), part.label], node?.fsVersion && [t("Version"), node.fsVersion], [t("File system size"), part.fsSize ? bytes(part.fsSize) : "—"], [t("Mount points"), part.mountpoints.join(", ") || "—"], part.usage && [t("Usage"), `${bytes(part.usage.used)} / ${bytes(part.usage.size)} (${part.usage.source === "df" ? t("mounted, df") : t("superblock")})`], [t("Status"), statusText(part)], part.systemRoles.length && [t("System roles"), part.systemRoles.join(", ")], node?.crypt && [t("LUKS"), `${node.crypt.type || ""} ${node.crypt.cipher || ""}`], !part.kernelMatches && part.kernel && [t("Kernel"), t("The kernel uses a different size than the partition table (a reboot may be pending).")] ]); } function showNodeDetails(node) { showFacts(node.displayPath || node.path, [ [t("Kernel name"), node.kname], [t("Type"), node.kind], [t("Size"), bytes(node.size)], [t("File system"), fsName(node.fstype)], [t("UUID"), node.uuid], [t("Label"), node.label], [t("Mount points"), node.mountpoints.join(", ") || "—"], node.usage && [t("Usage"), `${bytes(node.usage.used)} / ${bytes(node.usage.size)}`], node.fsSize && [t("File system size"), bytes(node.fsSize)], node.lvm && [t("LVM"), `${node.lvm.vg}/${node.lvm.name} · ${node.lvm.segtype || ""} ${node.lvm.thinVolume ? `thin (pool ${node.lvm.pool})` : ""}`], node.crypt && [t("LUKS"), `${node.crypt.type || ""} · ${node.crypt.cipher || ""} · ${t("key location")}: ${node.crypt.keyLocation || "—"}`], node.pv && [t("LVM PV"), `VG ${node.pv.vg || "—"} · ${bytes(node.pv.size)} (${t("free")} ${bytes(node.pv.free)})`], node.btrfs && [t("Btrfs"), `devid ${node.btrfs.devid} / ${node.btrfs.devices} · ${t("allocated")} ${bytes(node.btrfs.allocated)}`], node.zfsPool && [t("ZFS pool"), node.zfsPool], node.loopBacking && [t("Image file"), node.loopBacking], [t("Built from"), (node.parents || []).join(", ") || "—"], [t("Used by"), (node.children || []).join(", ") || "—"], [t("Status"), statusText(null, node)] ]); } function showFacts(title, entries) { el.detailTitle.textContent = title; el.detailFacts.replaceChildren(...entries.filter((entry) => entry && entry[1] !== null && entry[1] !== undefined && entry[1] !== "").map(([label, value]) => fact(label, String(value)))); el.detailPanel.hidden = false; } // ---------- capability checks (mirror Core rules; Core validates again) ---------- const SHRINKABLE = new Set(["ext2", "ext3", "ext4", "btrfs", "ntfs", "swap"]); const GROWABLE = new Set(["ext2", "ext3", "ext4", "xfs", "btrfs", "ntfs", "swap", "crypto_LUKS", "LVM2_member"]); const tool = (name) => Boolean(state.inventory?.tools?.[name]); function resizeLimits(view, segment) { const disk = view.disk; const part = segment.part; const lbs = disk.logicalSectorSize; const align = disk.alignSectors; if (!part || !manageable() || !view.table || part.extended || part.logical) return { allowed: false, reason: t("Not available for this partition.") }; if (!tool("sfdisk")) return { allowed: false, reason: t("sfdisk is not installed on the server.") }; const index = view.segments.indexOf(segment); const next = view.segments[index + 1]; const maxSectors = segment.size + (next?.kind === "free" ? next.size : 0); const fstype = part.fstype; const node = state.inventory.nodes.find((entry) => entry.kname === part.kernel); // A queued unmount makes the partition free in the simulated layout. const inUse = part.inUse && !state.queue.some((op) => op.type === "unmount" && op.device === part.path); let minSectors = segment.size; let shrinkReason = ""; if (!fstype) minSectors = align; else if (!SHRINKABLE.has(fstype)) shrinkReason = fstype === "xfs" ? t("XFS cannot be shrunk with the standard tools. Create a new, smaller file system and migrate the data.") : t("{fs} cannot be shrunk by CH-J.", { fs: fsName(fstype) }); else if (disk.protected) shrinkReason = t("Proxmox host storage — protected"); else if (/^ext/.test(fstype) && inUse) shrinkReason = part.systemRoles.length ? t("Mounted system file system: shrinking ext4 is possible only offline in a rescue/live system.") : t("Unmount the file system first (queue an unmount operation)."); else if (fstype === "swap" && node?.swapActive) shrinkReason = t("Active swap is never disabled automatically."); else if (fstype === "ntfs" && inUse) shrinkReason = t("Unmount the file system first (queue an unmount operation)."); else { const used = fstype === "btrfs" ? node?.btrfs?.allocated ?? part.usage?.used : part.usage?.used; const reserve = fstype === "btrfs" ? 256 * MiB : 128 * MiB; minSectors = fstype === "swap" ? align : known(used) ? Math.ceil((used + Math.max(used / 10, reserve)) / lbs / align) * align : segment.size; if (!known(used) && fstype !== "swap") shrinkReason = t("The usage is unknown, so the safe minimum cannot be computed."); } const canGrow = maxSectors > segment.size && (!fstype || GROWABLE.has(fstype)); return { allowed: canGrow || minSectors < segment.size, minSectors: Math.min(minSectors, segment.size), maxSectors, canGrow, shrinkReason, growReason: next?.kind === "free" ? (GROWABLE.has(fstype) || !fstype ? "" : t("{fs} cannot be resized by CH-J.", { fs: fsName(fstype) })) : t("There is no unallocated space directly after the partition.") }; } function menuItems(segment, view) { const disk = view.disk; const part = segment.part; const items = []; const add = (label, enabled, reason, action) => items.push({ label, enabled: Boolean(enabled) && manageable(), reason: !manageable() ? t("Read-only: the SSH account has no sudo rights.") : reason, action }); if (segment.kind === "free") { add(t("New partition…"), tool("sfdisk") && !disk.protected, disk.protected ? t("Proxmox host storage — protected") : t("sfdisk is not installed on the server."), () => openCreate(segment, view)); return items; } items.push({ label: t("Details"), enabled: true, action: () => showDetails(segment) }); if (segment.kind === "whole") { const node = segment.node; add(t("Format…"), node && !node.inUse && !disk.protected, t("The device is in use."), () => openFormat(node.path, node)); return items; } if (!part) return items; const node = state.inventory.nodes.find((entry) => entry.kname === part.kernel); const limits = resizeLimits(view, segment); const bootPart = disk.system && ["bios-boot", "esp"].includes(part.typeName); const editable = !view.preview || !segment.created; add(t("Resize…"), limits.allowed && editable, limits.growReason || limits.shrinkReason || limits.reason, () => openResize(segment, view)); items.push({ label: t("Move / copy"), enabled: false, reason: t("Moving and copying partitions is prepared but not enabled until a reliable, tested mechanism exists."), action: null }); add(t("Delete"), !part.inUse && !part.extended && !part.logical && !disk.protected && !bootPart, bootPart ? t("Boot partition of the disk that runs the system.") : part.inUse ? statusText(part) : disk.protected ? t("Proxmox host storage — protected") : t("Extended and logical MBR partitions are read-only."), () => queueOperation({ type: "delete-partition", disk: disk.name, number: part.number }, t("Delete partition {path}? All data on it will be lost.", { path: part.path }))); add(t("Format…"), !part.inUse && !disk.protected && !part.extended && !bootPart, bootPart ? t("Boot partition of the disk that runs the system.") : part.inUse ? statusText(part) : t("Proxmox host storage — protected"), () => openFormat(part.path, node)); add(t("Check file system…"), ["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat"].includes(part.fstype) && !part.inUse, part.inUse ? t("Checks run only on unmounted file systems.") : t("Not available for this content."), () => openCheck(part.path, part.fstype)); add(t("Grow file system to fill the partition"), part.fsSize && part.bytes - part.fsSize >= MiB && GROWABLE.has(part.fstype), t("The file system already fills the partition."), () => queueOperation({ type: "grow-fs", device: part.path })); add(t("Label…"), ["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat", "ntfs", "swap"].includes(part.fstype), t("Not available for this content."), () => openLabel(part.path, part.fstype, part.label)); add(t("Partition name…"), view.table?.type === "gpt", t("Partition names exist only in GPT."), () => openName(disk, part)); add(t("Flags…"), !part.extended && !part.logical, t("Extended and logical MBR partitions are read-only."), () => openFlags(disk, part)); if (part.mountpoints.length) add(t("Unmount"), !part.mountpoints.some((point) => /^(\/|\/boot(\/.*)?|\/efi|\/usr(\/.*)?|\/var(\/.*)?|\/home|\/opt|\/srv|\/root|\/tmp|\/nix|\/snap(\/.*)?)$/.test(point)), t("This mount point is needed by the running system; CH-J never unmounts it."), () => queueOperation({ type: "unmount", device: part.path })); else add(t("Mount…"), ["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat", "ntfs"].includes(part.fstype), t("No mountable file system."), () => openMount(part.path)); return items; } function openContextMenu(x, y, segment, view) { const menu = el.contextMenu; menu.replaceChildren(...menuItems(segment, view).map((item) => h("button", { type: "button", role: "menuitem", disabled: !item.enabled, title: item.enabled ? "" : item.reason || "", onclick: () => { closeContextMenu(); item.action?.(); } }, [item.label, !item.enabled && item.reason ? h("small", { text: item.reason }) : null]))); menu.hidden = false; const box = menu.getBoundingClientRect(); menu.style.left = `${Math.min(x, window.innerWidth - box.width - 8)}px`; menu.style.top = `${Math.min(y, window.innerHeight - box.height - 8)}px`; menu.querySelector("button:not([disabled])")?.focus(); } function closeContextMenu() { el.contextMenu.hidden = true; } // ---------- forms ---------- function unitInput(id, sectors, lbs) { const valueBytes = sectors * lbs; const unit = valueBytes >= 1024 ** 4 ? "TiB" : valueBytes >= 1024 ** 3 ? "GiB" : "MiB"; const factor = { MiB, GiB: 1024 ** 3, TiB: 1024 ** 4 }[unit]; return h("span", { className: "unit-input" }, [ h("input", { id, type: "number", min: "0", step: "any", value: String(Math.floor(valueBytes / factor * 1000) / 1000) }), h("select", { id: `${id}Unit` }, ["MiB", "GiB", "TiB"].map((name) => h("option", { value: name, selected: name === unit, text: name }))) ]); } function readUnit(id, lbs) { const value = Number(document.getElementById(id).value); const factor = { MiB, GiB: 1024 ** 3, TiB: 1024 ** 4 }[document.getElementById(`${id}Unit`).value]; return Number.isFinite(value) && value > 0 ? Math.floor(value * factor / lbs) : NaN; } function field(label, control, hint) { return h("label", {}, [h("span", { text: label }), control, hint && h("small", { className: "hint", text: hint })]); } function fsOptions(selected = "ext4", includeNone = false) { const tools = { ext4: "mkfs.ext4", xfs: "mkfs.xfs", btrfs: "mkfs.btrfs", vfat: "mkfs.vfat", ntfs: "mkfs.ntfs", swap: "mkswap" }; const options = Object.entries(tools).map(([fs, toolName]) => h("option", { value: fs, disabled: !tool(toolName), selected: fs === selected, text: `${fs === "vfat" ? "FAT32" : fs === "swap" ? "Linux swap" : fs.toUpperCase()}${tool(toolName) ? "" : ` — ${t("{tool} missing", { tool: toolName })}`}` })); if (includeNone) options.unshift(h("option", { value: "none", text: t("unformatted") })); return options; } let formHandler = null; function openForm(title, body, onSubmit, submitText) { el.formTitle.textContent = title; el.formBody.replaceChildren(...[].concat(body)); el.formError.hidden = true; el.formSubmit.textContent = submitText || t("Add to queue"); formHandler = onSubmit; el.formDialog.showModal(); el.formBody.querySelector("input,select")?.focus(); } function formError(message) { el.formError.textContent = message; el.formError.hidden = false; } function miniMap(view, highlight) { if (!view.segments?.length) { const node = nodeByPath(view.path); view = { ...view, segments: [{ kind: node?.fstype ? "whole" : "free", bytes: view.size || 1, fstype: node?.fstype || null, path: view.path }] }; } const total = view.segments.reduce((sum, segment) => sum + segment.bytes, 0) || 1; return h("div", { className: "disk-map mini" }, view.segments.map((segment) => h("div", { className: `segment fs-${fsClass(segment.fstype, segment.kind)}${highlight?.(segment) ? " selected" : ""}`, style: { flexGrow: String(segment.bytes / total * 1000) }, title: `${segment.path || t("unallocated")} ${bytes(segment.bytes)}` }, [h("span", { className: "seg-text" }, [h("small", { text: segment.kind === "free" ? "" : (segment.path || "").replace("/dev/", "") })])]))); } function openResize(segment, view, proposed) { const disk = view.disk; const part = segment.part; const lbs = disk.logicalSectorSize; const limits = resizeLimits(view, segment); const target = proposed || segment.size; const range = h("input", { type: "range", min: String(limits.minSectors), max: String(limits.maxSectors), step: String(disk.alignSectors), value: String(target), "aria-label": t("New size") }); const input = unitInput("resizeSize", target, lbs); const summary = h("p", { className: "hint" }); const preview = h("div"); const update = (sectors) => { const after = { ...view, segments: view.segments.flatMap((entry, index) => { if (entry === segment) return [{ ...entry, size: sectors, bytes: sectors * lbs }]; const previous = view.segments[index - 1]; if (previous === segment && entry.kind === "free") { const rest = entry.size - (sectors - segment.size); return rest * lbs >= MiB ? [{ ...entry, size: rest, bytes: rest * lbs }] : []; } return [entry]; }) }; if (sectors < segment.size && !view.segments.some((entry, index) => view.segments[index - 1] === segment && entry.kind === "free")) after.segments.splice(after.segments.findIndex((entry) => entry.number === segment.number) + 1, 0, { kind: "free", size: segment.size - sectors, bytes: (segment.size - sectors) * lbs }); preview.replaceChildren(h("small", { text: t("Current layout") }), miniMap(view, (entry) => entry === segment), h("small", { text: t("Resulting layout") }), miniMap(after, (entry) => entry.number === segment.number)); summary.textContent = t("{old} → {new}. Allowed: {min} – {max}.", { old: bytes(segment.bytes), new: bytes(sectors * lbs), min: bytes(limits.minSectors * lbs), max: bytes(limits.maxSectors * lbs) }); }; range.addEventListener("input", () => { const value = Number(range.value); document.getElementById("resizeSize").value = String(Math.floor(value * lbs / { MiB, GiB: 1024 ** 3, TiB: 1024 ** 4 }[document.getElementById("resizeSizeUnit").value] * 1000) / 1000); update(value); }); input.addEventListener("input", () => { const value = readUnit("resizeSize", lbs); if (Number.isFinite(value)) { range.value = String(value); update(Math.max(limits.minSectors, Math.min(limits.maxSectors, value))); } }); const max = h("button", { type: "button", className: "button small", text: t("Use all adjacent free space"), disabled: !limits.canGrow, onclick: () => { range.value = String(limits.maxSectors); range.dispatchEvent(new Event("input")); } }); const notes = [limits.shrinkReason && h("p", { className: "hint warn", text: `${t("Shrinking")}: ${limits.shrinkReason}` }), !limits.canGrow && limits.growReason && h("p", { className: "hint", text: `${t("Growing")}: ${limits.growReason}` }), part?.fstype === "xfs" && h("p", { className: "hint", text: t("XFS can only be grown (online, while mounted). Shrinking is not supported by the XFS tools.") }), /^ext/.test(part?.fstype || "") && h("p", { className: "hint", text: t("ext4 grows online; it is shrunk only unmounted: check (e2fsck), minimum size with a safety reserve, shrink the file system, then the partition, then verify.") })]; openForm(t("Resize {path}", { path: part.path }), [preview, range, field(t("New size"), input), max, summary, ...notes.filter(Boolean)], () => { let size = readUnit("resizeSize", lbs); if (Number(range.value) === limits.maxSectors && Math.abs(size - limits.maxSectors) * lbs < MiB) size = limits.maxSectors; if (!Number.isFinite(size) || size < limits.minSectors || size > limits.maxSectors) return formError(t("The size must be between {min} and {max}.", { min: bytes(limits.minSectors * lbs), max: bytes(limits.maxSectors * lbs) })); return { type: "resize-partition", disk: disk.name, number: part.number, size: size === limits.maxSectors ? "max" : size }; }); update(target); } function openCreate(segment, view) { const disk = view.disk; const lbs = disk.logicalSectorSize; const gpt = view.table?.type === "gpt"; const body = [ h("p", { className: "hint", text: t("Unallocated space: {size} (sectors {start}–{end}).", { size: bytes(segment.bytes), start: segment.start, end: segment.end }) }), field(t("Size"), unitInput("createSize", segment.size, lbs), t("Aligned to 1 MiB. The maximum uses the whole free area.")), field(t("File system"), h("select", { id: "createFs" }, fsOptions("ext4", true))), field(t("Label"), h("input", { id: "createLabel", maxlength: "16", pattern: "[A-Za-z0-9._-]*" }), t("Optional; A–Z, a–z, 0–9, dot, dash, underscore.")), gpt && field(t("Partition name (GPT)"), h("input", { id: "createName", maxlength: "36" })), h("label", { className: "check" }, [h("input", { id: "createEsp", type: "checkbox" }), t("EFI system partition (FAT32 only)")]) ]; openForm(t("New partition on {disk}", { disk: disk.path }), body.filter(Boolean), () => { let size = readUnit("createSize", lbs); if (!Number.isFinite(size)) return formError(t("Enter a valid size.")); const fs = document.getElementById("createFs").value; if (Math.abs(size - segment.size) * lbs < MiB || size > segment.size) size = "max"; return { type: "create-partition", disk: disk.name, start: segment.start, size, fs, label: document.getElementById("createLabel").value.trim() || undefined, name: gpt ? document.getElementById("createName").value.trim() || undefined : undefined, typeFlag: document.getElementById("createEsp").checked ? "esp" : undefined }; }); } function openFormat(path, node) { openForm(t("Format {path}", { path }), [ h("p", { className: "hint warn", text: t("Formatting destroys all data on {path}.", { path }) }), field(t("File system"), h("select", { id: "formatFs" }, fsOptions(node?.fstype && node.fstype !== "LVM2_member" ? node.fstype : "ext4"))), field(t("Label"), h("input", { id: "formatLabel", maxlength: "16" })) ], () => ({ type: "format", device: path, fs: document.getElementById("formatFs").value, label: document.getElementById("formatLabel").value.trim() || undefined })); } function openCheck(path, fstype) { openForm(t("Check {path}", { path }), [ h("label", { className: "check" }, [h("input", { type: "radio", name: "checkMode", value: "check", checked: true }), t("Read-only check (no changes)")]), h("label", { className: "check" }, [h("input", { type: "radio", name: "checkMode", value: "repair", disabled: fstype === "btrfs" }), t("Check and repair automatically safe problems")]), fstype === "btrfs" && h("p", { className: "hint", text: t("btrfs check --repair is not offered: it can make damage worse.") }) ].filter(Boolean), () => ({ type: "check", device: path, repair: document.querySelector("input[name=checkMode]:checked")?.value === "repair" })); } function openLabel(path, fstype, current) { const limit = { ext2: 16, ext3: 16, ext4: 16, xfs: 12, btrfs: 36, vfat: 11, ntfs: 32, swap: 15 }[fstype] || 16; openForm(t("Label of {path}", { path }), [field(t("Label"), h("input", { id: "labelValue", maxlength: String(limit), value: current || "" }), t("Max. {limit} characters: A–Z, a–z, 0–9, dot, dash, underscore. Empty removes the label.", { limit })), ["xfs", "vfat", "ntfs"].includes(fstype) && h("p", { className: "hint", text: t("This file system must be unmounted to change its label.") })].filter(Boolean), () => ({ type: "set-label", device: path, label: document.getElementById("labelValue").value.trim() || undefined })); } function openName(disk, part) { openForm(t("Partition name of {path}", { path: part.path }), [field(t("Partition name (GPT)"), h("input", { id: "nameValue", maxlength: "36", value: part.name || "" }), t("Empty removes the name."))], () => ({ type: "set-name", disk: disk.name, number: part.number, name: document.getElementById("nameValue").value.trim() || undefined })); } function openFlags(disk, part) { const gpt = disk.table?.type === "gpt"; const flags = gpt ? ["legacy_boot", "esp", "bios-boot", "lvm", "raid", "swap", "basic-data"] : ["boot", "esp", "lvm", "raid", "swap"]; const current = new Set(flagsOf(part)); const exclusive = flags.filter((flag) => !["boot", "legacy_boot"].includes(flag)); openForm(t("Flags of {path}", { path: part.path }), [ ...flags.map((flag) => h("label", { className: "check" }, [h("input", { type: exclusive.includes(flag) ? "radio" : "checkbox", name: exclusive.includes(flag) ? "flagType" : `flag-${flag}`, value: flag, checked: current.has(flag) }), flag])), h("label", { className: "check" }, [h("input", { type: "radio", name: "flagType", value: "", checked: !exclusive.some((flag) => current.has(flag)) }), t("no type flag (Linux data)")]), h("p", { className: "hint", text: t("Type flags change the partition type GUID/ID; only one can be active. Partitions used by the running system keep their type.") }) ], () => { const ops = []; for (const flag of ["boot", "legacy_boot"].filter((name) => flags.includes(name))) { const on = document.querySelector(`input[name=flag-${flag}]`).checked; if (on !== current.has(flag)) ops.push({ type: "set-flag", disk: disk.name, number: part.number, flag, enabled: on }); } const selected = document.querySelector("input[name=flagType]:checked")?.value || ""; const active = exclusive.find((flag) => current.has(flag)) || ""; if (selected !== active) ops.push(selected ? { type: "set-flag", disk: disk.name, number: part.number, flag: selected, enabled: true } : { type: "set-flag", disk: disk.name, number: part.number, flag: active, enabled: false }); if (!ops.length) return formError(t("Nothing changed.")); return ops; }); } function openMount(path) { openForm(t("Mount {path}", { path }), [field(t("Mount point"), h("input", { id: "mountPoint", value: `/mnt/${path.split("/").pop()}`, maxlength: "120" }), t("Below /mnt or /media. The mount is temporary; /etc/fstab is not changed."))], () => ({ type: "mount", device: path, mountpoint: document.getElementById("mountPoint").value.trim() })); } function openTable() { const disk = currentDisk(); if (!disk) return; openForm(t("New partition table on {disk}", { disk: disk.path }), [ h("p", { className: "hint warn", text: t("A new partition table removes ALL partitions and makes all data on {disk} inaccessible.", { disk: disk.path }) }), h("label", { className: "check" }, [h("input", { type: "radio", name: "tableType", value: "gpt", checked: true }), "GPT"]), h("label", { className: "check" }, [h("input", { type: "radio", name: "tableType", value: "dos" }), t("MBR (dos) — max. 4 primary partitions, 2 TiB")]) ], () => ({ type: "create-table", disk: disk.name, label: document.querySelector("input[name=tableType]:checked").value })); } function openLvExtend(lv, vg) { openForm(t("Extend {lv}", { lv: `${lv.vg}/${lv.name}` }), [ h("p", { className: "hint", text: t("Volume group {vg} has {free} free.", { vg: vg.name, free: bytes(vg.free) }) }), h("label", { className: "check" }, [h("input", { id: "lvMax", type: "checkbox", checked: true }), t("Use all free space of the volume group")]), field(t("New total size"), unitInput("lvSize", Math.floor((lv.size + vg.free) / 512), 512)), h("p", { className: "hint", text: t("The file system inside is grown in a separate step (ext4 online/offline, XFS and Btrfs online).") }) ], () => { if (document.getElementById("lvMax").checked) return { type: "lv-extend", lv: `${lv.vg}/${lv.name}`, size: "max" }; const size = readUnit("lvSize", 512) * 512; if (!Number.isFinite(size) || size <= lv.size) return formError(t("The new size must be larger than the current size.")); return { type: "lv-extend", lv: `${lv.vg}/${lv.name}`, size }; }); } function openLvReduce(lv, vg, node) { const used = node?.usage?.used; openForm(t("Reduce {lv} (advanced)", { lv: `${lv.vg}/${lv.name}` }), [ h("p", { className: "hint warn", text: t("Advanced operation: the ext file system is checked and shrunk first (minimum size plus reserve), then the logical volume is reduced. A wrong order or size destroys data; have a current backup.") }), field(t("New size"), unitInput("lvReduce", Math.floor(lv.size / 512), 512), known(used) ? t("Used: {used}", { used: bytes(used) }) : ""), h("label", { className: "check" }, [h("input", { id: "lvAdvanced", type: "checkbox" }), t("I understand the risk and have a backup")]) ], () => { if (!document.getElementById("lvAdvanced").checked) return formError(t("Confirm the advanced operation.")); const size = readUnit("lvReduce", 512) * 512; if (!Number.isFinite(size) || size >= lv.size) return formError(t("The new size must be smaller than the current size.")); return { type: "lv-reduce", lv: `${lv.vg}/${lv.name}`, size, advanced: true }; }); } // ---------- queue ---------- async function planQueue(operations) { const requestGeneration = state.generation; return withSudo((credentials) => api.disks.plan(state.sessionId, operations, { ...credentials })); } async function queueOperation(op, confirmText) { const requestGeneration = state.generation; if (confirmText && !window.confirm(confirmText)) return; const operations = [...state.queue, ...[].concat(op)]; state.busy = true; updateButtons(); setStatus(t("Validating the operation…"), "busy"); try { const plan = await sessionResult(planQueue(operations), requestGeneration); if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {}); state.queue = operations; state.preview = plan; if (el.formDialog.open) el.formDialog.close(); setStatus(t("Operation queued. Nothing has been changed on the server yet.")); render(); } catch (error) { if (requestGeneration !== state.generation) return; if (cancelled(error)) { setStatus(""); return; } if (el.formDialog.open) formError(describe(error)); else setStatus(describe(error), "error"); } finally { if (requestGeneration === state.generation) { state.busy = false; updateButtons();} } } async function rebuildPreview() { const requestGeneration = state.generation; if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {}); state.preview = null; if (state.queue.length) { try { state.preview = await sessionResult(planQueue(state.queue), requestGeneration); } catch (error) { if (requestGeneration !== state.generation) return; state.queue = []; setStatus(t("The queue no longer fits the current layout and was cleared: {error}", { error: describe(error) }), "error"); } } render(); } function describeOperation(op) { const sizeText = (value, disk) => value === "max" ? t("maximum") : bytes(value * (diskByName(disk)?.logicalSectorSize || 512)); switch (op.type) { case "create-table": return t("New {type} partition table on {disk}", { type: op.label === "dos" ? "MBR" : "GPT", disk: `/dev/${op.disk}` }); case "create-partition": return t("New partition on {disk}: {size}, {fs}", { disk: `/dev/${op.disk}`, size: sizeText(op.size, op.disk), fs: op.fs === "none" ? t("unformatted") : op.fs }); case "delete-partition": return t("Delete partition {number} of {disk}", { number: op.number, disk: `/dev/${op.disk}` }); case "resize-partition": return t("Resize partition {number} of {disk} to {size}", { number: op.number, disk: `/dev/${op.disk}`, size: sizeText(op.size, op.disk) }); case "format": return t("Format {device} as {fs}", { device: op.device, fs: op.fs }); case "set-label": return t("Label of {device}: {label}", { device: op.device, label: op.label || "—" }); case "set-name": return t("Name of partition {number} of {disk}: {name}", { number: op.number, disk: `/dev/${op.disk}`, name: op.name || "—" }); case "set-flag": return t("Flag {flag} of partition {number} of {disk}: {state}", { flag: op.flag, number: op.number, disk: `/dev/${op.disk}`, state: op.enabled ? t("on") : t("off") }); case "check": return t(op.repair ? "Check and repair {device}" : "Check {device} (read-only)", { device: op.device }); case "grow-fs": return t("Grow the file system on {device}", { device: op.device }); case "mount": return t("Mount {device} at {mountpoint}", { device: op.device, mountpoint: op.mountpoint }); case "unmount": return t("Unmount {device}", { device: op.device }); case "lv-extend": return t("Extend {lv} to {size}", { lv: op.lv, size: op.size === "max" ? t("maximum") : bytes(op.size) }); case "lv-reduce": return t("Reduce {lv} to {size}", { lv: op.lv, size: bytes(op.size) }); case "pv-resize": return t("Resize physical volume {device}", { device: op.device }); default: return op.type; } } const diskByName = (name) => state.inventory?.disks?.find((disk) => disk.name === name); function stepText(name, args) { const disk = diskByPath(args[0]); const lbs = disk?.logicalSectorSize || 512; const sectors = (value) => bytes(Number(value) * lbs); switch (name) { case "verify-disk": return t("Verify that {disk} still has the planned size and partition table", { disk: args[0] }); case "verify-size": return t("Verify the current size of {device}", { device: args[0] }); case "backup-table": return t("Save the partition table metadata of {disk} on the server (this is not a data backup)", { disk: args[0] }); case "require-free": return t("Check that {device} is not in use", { device: args[0] }); case "gpt-relocate": return t("Move the backup GPT header of {disk} to the end of the disk", { disk: args[0] }); case "create-table": return t("Write a new empty {type} partition table to {disk}", { type: args[1] === "dos" ? "MBR" : "GPT", disk: args[0] }); case "create-part": return t("Create partition {number} on {disk} ({size})", { number: args[1], disk: args[0], size: sectors(args[3]) }); case "delete-part": return t("Delete partition {number} of {disk} ({size})", { number: args[1], disk: args[0], size: sectors(args[3]) }); case "resize-part": return t("Move the end of partition {number} on {disk}: {old} → {new}", { number: args[1], disk: args[0], old: sectors(args[3]), new: sectors(args[4]) }); case "set-type": return t("Set the type of partition {number} on {disk} to {type}", { number: args[1], disk: args[0], type: args[2] }); case "set-name": return t("Set the name of partition {number} on {disk}", { number: args[1], disk: args[0] }); case "set-attrs": return t("Set the GPT attributes of partition {number} on {disk}", { number: args[1], disk: args[0] }); case "set-boot": return t("Set the boot flag on {disk}", { disk: args[0] }); case "mkfs": return t("Create {fs} on {device}", { fs: args[1], device: args[0] }); case "set-label": return t("Set the label of {device}", { device: args[0] }); case "fsck": return t(args[2] === "repair" ? "Check and repair the file system on {device}" : "Check the file system on {device} (read-only)", { device: args[0] }); case "grow-fs": return t("Grow {fs} on {device} to the size of the device", { fs: args[1], device: args[0] }); case "shrink-fs": return t("Check and shrink {fs} on {device} to {size}", { fs: args[1], device: args[0], size: bytes(Number(args[2])) }); case "luks-resize": return t("Resize the LUKS mapping {name}", { name: args[0] }); case "pv-resize": return t("Resize the LVM physical volume {device}", { device: args[0] }); case "lv-extend": return t("Extend the logical volume {lv}", { lv: args[0] }); case "lv-reduce": return t("Reduce the logical volume {lv} to {size}", { lv: args[0], size: bytes(Number(args[1])) }); case "mount": return t("Mount {device} at {mountpoint}", { device: args[0], mountpoint: args[1] }); case "unmount": return t("Unmount {device}", { device: args[0] }); case "verify-part": return t("Verify the new geometry of partition {number} on {disk}", { number: args[1], disk: args[0] }); case "verify-fs": return t("Verify the file system on {device}", { device: args[0] }); default: return [name, ...args].join(" "); } } // ---------- apply ---------- async function openApply(plan) { const requestGeneration = state.generation; const sessionId = state.sessionId; const applyGeneration = state.generation; const body = [h("p", { text: api.sessions.displayName(state.sessions.find((session) => session.sessionId === sessionId)) })]; body.push(h("h4", { text: t("Current layout") }), ...plan.before.map((view) => h("div", {}, [h("small", { text: view.path }), miniMap(view)]))); body.push(h("h4", { text: t("Resulting layout") }), ...plan.after.map((view) => h("div", {}, [h("small", { text: view.path }), miniMap(view, (segment) => segment.created)]))); body.push(h("h4", { text: t("Steps") }), h("ol", { className: "step-list" }, plan.steps.map((entry) => h("li", { className: entry.destructive ? "destructive" : entry.kind }, [stepText(entry.name, entry.args), entry.destructive ? h("i", { className: "pill danger", text: t("changes data") }) : null])))); const warnings = []; if (plan.dataLoss) warnings.push(t("Risk of data loss: some steps delete, overwrite or shrink data. Make a separate backup or snapshot of the data first.")); if (plan.backupDisks.length) warnings.push(t("The partition table metadata of {disks} is saved on the server before writing. This is not a backup of the data.", { disks: plan.backupDisks.join(", ") })); if (plan.offline.length) warnings.push(t("These file systems must stay unmounted during the job: {devices}.", { devices: plan.offline.join(", ") })); if (plan.mayRequireReboot) warnings.push(t("A partition in use is changed. If the running kernel does not accept the new size, the job stops safely and asks for a reboot; it is never rebooted automatically.")); for (const note of plan.notes || []) warnings.push(note.code === "VG_FREE_LEFT" ? t("Volume group {vg} gains free space; extend a logical volume afterwards (no unique candidate).", { vg: note.vg }) : note.code === "LUKS_CLOSED" ? t("The LUKS container {device} is closed; the mapping uses the new size when it is opened next time.", { device: note.device }) : note.code); warnings.push(t("The job runs on the server independently of this SSH connection. Once started it cannot be interrupted safely; it is never restarted automatically.")); body.push(h("ul", { className: "warnings" }, warnings.map((text) => h("li", { text })))); if (plan.dataLoss) body.push(h("label", { className: "check" }, [h("input", { id: "applyBackup", type: "checkbox" }), t("I have a current backup or snapshot of the affected data")])); el.applyBody.replaceChildren(...body); el.applyError.hidden = true; el.applyDialog.showModal(); const submit = async (event) => { const requestGeneration = state.generation; try { event.preventDefault(); if (applyGeneration !== state.generation || sessionId !== state.sessionId) return; if (plan.dataLoss && !document.getElementById("applyBackup").checked) { el.applyError.textContent = t("Confirm that a backup exists."); el.applyError.hidden = false; return; } el.applySubmit.disabled = true; try { const result = await sessionResult(withSudo((credentials) => api.disks.apply(sessionId, plan.planId, { confirm: true, rememberSudo: true, ...credentials })), requestGeneration); el.applyDialog.close(); state.queue = []; state.preview = null; startJobMonitor(result.jobId, plan.title); render(); } catch (error) { if (requestGeneration !== state.generation) return; if (cancelled(error)) return; el.applyError.textContent = describe(error); el.applyError.hidden = false; if (["DISKS_PLAN_STALE", "DISKS_PLAN_EXPIRED"].includes(errorCode(error))) { el.applySubmit.disabled = true; await sessionResult(loadInventory({ privileged: true }), requestGeneration); await sessionResult(rebuildPreview(), requestGeneration); return; } if (errorCode(error) === "SESSION_NOT_CONNECTED") { el.applyError.textContent += ` ${t("If the job was submitted before the connection dropped, it appears in “Jobs on server” after reconnecting; the plan is not started again.")}`; state.queue = []; state.preview = null; render(); } } finally { if (requestGeneration === state.generation) { el.applySubmit.disabled = false;} } } catch (error) { if (requestGeneration !== state.generation) return; throw error; } }; el.applyForm.onsubmit = submit; } async function applyQueue() { const requestGeneration = state.generation; if (!state.queue.length) return; state.busy = true; updateButtons(); setStatus(t("Re-reading the current layout and validating the plan…"), "busy"); try { const plan = await sessionResult(planQueue(state.queue), requestGeneration); if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {}); state.preview = plan; render(); await sessionResult(openApply(plan), requestGeneration); setStatus(""); } catch (error) { if (requestGeneration !== state.generation) return; if (!cancelled(error)) setStatus(describe(error), "error"); } finally { if (requestGeneration === state.generation) { state.busy = false; updateButtons();} } } async function autoExpand(suggestion) { const requestGeneration = state.generation; state.busy = true; updateButtons(); setStatus(t("Re-reading the layout and planning the expansion…"), "busy"); try { const plan = await sessionResult(withSudo((credentials) => api.disks.autoExpand(state.sessionId, suggestion.id, credentials)), requestGeneration); setStatus(""); await sessionResult(openApply(plan), requestGeneration); } catch (error) { if (requestGeneration !== state.generation) return; if (!cancelled(error)) { setStatus(describe(error), "error"); if (errorCode(error) === "DISKS_SUGGESTION_GONE") loadInventory({ privileged: true }); } } finally { if (requestGeneration === state.generation) { state.busy = false; updateButtons();} } } // ---------- jobs ---------- function stopJobPolling() { clearTimeout(state.jobTimer); state.jobTimer = null; } function startJobMonitor(jobId, title) { stopJobPolling(); state.job = { id: jobId, title: title || "", offset: 0, log: "", steps: [], plan: [], outcome: "running", state: "queued", error: null, disconnected: false }; el.jobPanel.hidden = false; renderJob(); pollJob(); } async function pollJob() { const requestGeneration = state.generation; const job = state.job; if (!job || !state.sessionId) return; stopJobPolling(); const token = state.generation; try { const status = await sessionResult(withSudo((credentials) => api.disks.jobStatus(state.sessionId, job.id, job.offset, credentials), t("Monitoring the job needs the sudo password again.")), requestGeneration); if (token !== state.generation || state.job !== job) return; Object.assign(job, { state: status.state, outcome: status.outcome, step: status.step, total: status.total, error: status.error, plan: status.plan, title: status.title || job.title, alive: status.alive, disconnected: false }); job.log += status.log; job.offset = status.logOffset; for (const marker of status.steps) job.steps[marker.index - 1] = marker.status; renderJob(); if (job.outcome === "running") state.jobTimer = setTimeout(pollJob, 1500); else { setStatus(job.outcome === "succeeded" ? t("The disk job finished successfully.") : job.outcome === "pending-reboot" ? t("The job stopped: a reboot (or rescue system) is needed before the remaining steps.") : t("The disk job did not finish successfully."), job.outcome === "succeeded" ? "" : "error"); loadInventory({ privileged: true }); } } catch (error) { if (requestGeneration !== state.generation) return; if (cancelled(error)) return; if (errorCode(error) === "SESSION_NOT_CONNECTED" || errorCode(error) === "SESSION_NOT_FOUND") { job.disconnected = true; renderJob(); return; } job.pollError = describe(error); renderJob(); state.jobTimer = setTimeout(pollJob, 5000); } } function renderJob() { const job = state.job; if (!job) { el.jobPanel.hidden = true; return; } el.jobPanel.hidden = false; el.jobTitle.textContent = `${t("Disk job")} ${job.id.slice(0, 8)} — ${job.title}`; const text = job.disconnected ? t("Connection lost. The job continues on the server; the status is read again after reconnecting.") : { running: t("Running: step {step} of {total}", { step: job.step || 0, total: job.total || job.plan.length || "?" }), succeeded: t("Finished successfully and verified."), failed: t("Failed: {error}", { error: job.error ? describe(new Error(`${job.error.code}: ${job.error.message}`)) : "" }), "pending-reboot": t("Stopped: reboot required. {error}", { error: job.error?.message || "" }), unknown: t("Unknown: the runner is no longer active but did not record a result (e.g. server reboot). Check the disk manually; the job is never restarted automatically.") }[job.outcome] || job.state; el.jobState.textContent = `${text}${job.pollError ? ` (${job.pollError})` : ""}`; el.jobState.className = `job-state ${job.outcome}`; // No percentage: the tools report no reliable progress, so the bar is indeterminate while running. el.jobProgress.className = `progress ${job.outcome === "running" && !job.disconnected ? "indeterminate" : job.outcome}`; el.jobSteps.replaceChildren(...job.plan.map((line, index) => { const [name, ...args] = line.split(" "); const status = job.steps[index] || (index + 1 < (job.step || 0) ? "done" : ""); return h("li", { className: status }, [stepText(name, args), status ? h("i", { className: "pill", text: t(status) }) : null]); })); el.jobLog.textContent = job.log.slice(-200000); updateButtons(); renderSuggestions(); } async function checkJobs(silent = false) { const requestGeneration = state.generation; try { const { jobs } = await sessionResult(withSudo((credentials) => api.disks.jobs(state.sessionId, credentials)), requestGeneration); const running = jobs.find((job) => job.outcome === "running" || job.outcome === "unknown" && !silent); if (running && !state.job) startJobMonitor(running.id, running.title); if (!silent) showJobs(jobs); } catch (error) { if (requestGeneration !== state.generation) return; if (!silent && !cancelled(error)) setStatus(describe(error), "error"); } } function showJobs(jobs) { el.infoTitle.textContent = t("Disk jobs on this server"); el.infoBody.replaceChildren(jobs.length ? h("table", {}, [h("thead", {}, h("tr", {}, [t("Job"), t("Operations"), t("Status"), t("Created"), t("Finished"), ""].map((name) => h("th", { text: name })))), h("tbody", {}, jobs.map((job) => h("tr", {}, [ h("td", { text: job.id.slice(0, 8) }), h("td", { text: job.title }), h("td", { text: t(job.outcome) }), h("td", { text: job.created || "" }), h("td", { text: job.finished || "" }), h("td", {}, h("button", { className: "mini", type: "button", text: t("Show"), onclick: () => { el.infoDialog.close(); startJobMonitor(job.id, job.title); } })) ])))]) : h("p", { className: "hint", text: t("No disk jobs were run on this server.") }), h("p", { className: "hint", text: t("Job state and logs are kept on the server in /var/lib/chj-server-manager/disks (30 days).") })); el.infoDialog.showModal(); } // ---------- SMART ---------- async function showSmart() { const requestGeneration = state.generation; const disk = currentDisk(); if (!disk) return; setStatus(t("Reading health data…"), "busy"); try { const smart = await sessionResult(withSudo((credentials) => api.disks.smart(state.sessionId, disk.path, credentials)), requestGeneration); setStatus(""); el.infoTitle.textContent = t("Health of {disk}", { disk: disk.path }); const rows = smart.available ? [ [t("Overall assessment"), smart.passed === true ? t("passed") : smart.passed === false ? t("FAILED") : "—"], [t("Model"), smart.model], [t("Serial number"), smart.serial], [t("Firmware"), smart.firmware], [t("Capacity"), bytes(smart.capacity)], [t("Temperature"), known(smart.temperatureC) ? `${smart.temperatureC} °C` : null], [t("Power-on hours"), smart.powerOnHours], [t("Power cycles"), smart.powerCycles], [t("Reallocated sectors"), smart.reallocatedSectors], [t("Pending sectors"), smart.pendingSectors], [t("Offline uncorrectable"), smart.offlineUncorrectable], [t("CRC errors"), smart.crcErrors], [t("NVMe percentage used"), known(smart.percentageUsed) ? `${smart.percentageUsed} %` : null], [t("Available spare"), known(smart.availableSpare) ? `${smart.availableSpare} %` : null], [t("Media errors"), smart.mediaErrors], [t("Error log entries"), smart.errorLogEntries] ] : [[t("SMART"), smart.reason === "SMARTCTL_MISSING" ? t("smartctl (smartmontools) is not installed. CH-J does not install packages.") : t("Not available for this device (common for virtual disks): {reason}", { reason: smart.reason })]]; el.infoBody.replaceChildren(h("dl", { className: "facts two" }, rows.filter(([, value]) => value !== null && value !== undefined && value !== "").map(([label, value]) => fact(label, String(value)))), ...smart.messages.slice(0, 3).map((message) => h("p", { className: "hint", text: message }))); el.infoDialog.showModal(); } catch (error) { if (requestGeneration !== state.generation) return; if (!cancelled(error)) setStatus(describe(error), "error"); } } // ---------- events ---------- el.formForm.addEventListener("submit", (event) => { event.preventDefault(); const result = formHandler?.(); if (result && typeof result === "object") queueOperation(result); }); for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("click", (event) => { if (event.target.closest("[data-close]")) dialog.close(); }); document.addEventListener("click", (event) => { if (!el.contextMenu.hidden && !el.contextMenu.contains(event.target)) closeContextMenu(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeContextMenu(); }); el.closeButton.addEventListener("click", () => api.close()); el.sessionSelect.addEventListener("change", () => { endedSessionId = ""; const session = state.sessions.find((entry) => entry.sessionId === el.sessionSelect.value); resetServer(); Object.assign(state, { sessionId: session?.sessionId || "", identity: session ? `${session.profileId}|${session.host}|${session.port}|${session.username}` : "", disconnected: false }); if (session) loadInventory(); else render(); }); el.refreshButton.addEventListener("click", () => loadInventory({ privileged: Boolean(state.inventory?.privileged) })); el.privilegedButton.addEventListener("click", () => loadInventory({ privileged: true })); el.jobsButton.addEventListener("click", () => checkJobs(false)); el.smartButton.addEventListener("click", showSmart); el.tableButton.addEventListener("click", openTable); el.detailClose.addEventListener("click", () => { el.detailPanel.hidden = true; }); el.queueApply.addEventListener("click", applyQueue); el.queueClear.addEventListener("click", () => { state.queue = []; rebuildPreview(); setStatus(t("Queue cleared; nothing was changed on the server.")); }); el.queueUndo.addEventListener("click", () => { state.queue.pop(); rebuildPreview(); }); el.jobClose.addEventListener("click", () => { if (state.job?.outcome !== "running") { stopJobPolling(); state.job = null; } el.jobPanel.hidden = true; }); async function init() { try { i18n.setLanguage(await api.ui.getLanguage()); } catch {} i18n.apply(document); api.ui.onLanguageChanged((language) => { i18n.setLanguage(language); i18n.apply(document); render(); renderJob(); }); try { const info = await api.getInfo(); el.pluginVersion.textContent = `${info.id} · ${info.version}`; } catch {} await loadSessions(); } init(); setInterval(() => { if (!document.hidden) void loadSessions().catch(() => {}); }, 3000);