CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 406 Dateien
core-20261024T135205Z-7c487d719de2 Hinzugefügt: · Erstellt:
index.js Hinzugefügt: Datum nicht erfasst 78,4 KB · 985 Zeilen
Code Datei herunterladen
1"use strict";
3// chj.disks UI. All server data is rendered as text (never as HTML). Every change is queued, validated and
4// simulated by Core (plan), reviewed by the user and only then started as a remote job (apply).
5const api = window.chjPlugin;
6const i18n = window.chjDisksI18n.createI18n("en");
7const t = (key, values) => i18n.t(key, values);
8const el = new Proxy({}, { get: (_target, id) => document.getElementById(id) });
9const MiB = 1048576;
10const state = {
11 generation: 0, sessionId: "", identity: "", sessions: [], inventory: null, selectedDisk: null, selected: null,
12 queue: [], preview: null, busy: false, job: null, jobTimer: null, sudo: null
13};
15// ---------- helpers ----------
16function h(tag, props = {}, children = []) {
17 const node = document.createElement(tag);
18 for (const [key, value] of Object.entries(props)) {
19 if (value === undefined || value === null || value === false) continue;
20 if (key === "className") node.className = value;
21 else if (key === "text") node.textContent = String(value);
22 else if (key === "style") Object.assign(node.style, value);
23 else if (key === "dataset") Object.assign(node.dataset, value);
24 else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value);
25 else if (typeof value === "boolean") node[key] = value;
26 else node.setAttribute(key, String(value));
27 }
28 for (const child of [].concat(children)) if (child !== null && child !== undefined && child !== false) node.append(child instanceof Node ? child : String(child));
29 return node;
31const locale = () => ({ cs: "cs-CZ", de: "de-DE" }[i18n.getLanguage()] || "en-GB");
32const known = (value) => value !== null && value !== undefined && Number.isFinite(Number(value));
33function bytes(value) {
34 if (!known(value)) return "—";
35 const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; let amount = Number(value); let unit = 0;
36 while (Math.abs(amount) >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
37 return `${amount.toLocaleString(locale(), { maximumFractionDigits: unit >= 3 ? 2 : unit ? 1 : 0 })} ${units[unit]}`;
39const pct = (used, total) => known(used) && known(total) && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null;
40function setStatus(message, kind = "") { el.status.textContent = message || ""; el.status.className = `status ${kind}`; }
41function parseError(error) {
42 const raw = String(error?.message || error || "");
43 const match = raw.match(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+): ([\s\S]*)$/);
44 return match ? { code: match[1], detail: match[2].trim() } : { code: "UNKNOWN", detail: raw.replace(/^Error invoking remote method '[^']+': (?:\w*Error: )?/, "") };
46const ERROR_KEYS = {
47 SUDO_NOT_PERMITTED: "The SSH account has no sudo rights; disks are shown read-only.",
48 SUDO_PASSWORD_INCORRECT: "The sudo password was rejected.",
49 SUDO_TTY_REQUIRED: "The sudo policy requires a terminal (requiretty); CH-J does not bypass it.",
50 SUDO_UNAVAILABLE: "sudo is not installed and the SSH account is not root.",
51 SESSION_NOT_CONNECTED: "The SSH connection was lost. A running disk job continues on the server; its status is checked again after reconnecting.",
52 REMOTE_COMMAND_TIMEOUT: "The server did not respond in time.",
53 DISKS_PLAN_STALE: "The disk layout changed since the plan was reviewed. Nothing was changed; review the new plan.",
54 DISKS_PLAN_EXPIRED: "The plan expired or was already used. Nothing was changed.",
55 DISKS_LAYOUT_CHANGED: "The disk layout changed since planning; the job stopped before changing anything.",
56 DISKS_BUSY: "Another disk job is running on this server.",
57 DISKS_JOB_EXISTS: "This job was already submitted; its status is shown instead.",
58 DISKS_PROTECTED_DEVICE: "This device belongs to Proxmox host or guest storage; destructive changes are not offered.",
59 DISKS_DEVICE_IN_USE: "The device is in use (mounted, active swap or used by another layer). CH-J never releases it automatically.",
60 DISKS_SHRINK_REQUIRES_UNMOUNT: "Shrinking needs an unmounted file system.",
61 DISKS_XFS_SHRINK_UNSUPPORTED: "XFS cannot be shrunk with the standard tools. Create a new, smaller file system and migrate the data.",
62 DISKS_XFS_GROW_NEEDS_MOUNT: "XFS can only be grown while mounted.",
63 DISKS_FAT_RESIZE_UNSUPPORTED: "Resizing FAT is not implemented.",
64 DISKS_LUKS_PASSPHRASE_REQUIRED: "Resizing this LUKS mapping needs its passphrase; CH-J never handles LUKS secrets.",
65 DISKS_LUKS_SHRINK_UNSUPPORTED: "LUKS is never shrunk automatically.",
66 DISKS_TOOL_MISSING: "A required tool is not installed on the server. CH-J does not install packages.",
67 DISKS_SIZE_TOO_SMALL: "The size is below the safe minimum.",
68 DISKS_NO_FREE_SPACE: "There is no unallocated space directly after the partition.",
69 DISKS_OPERATION_DISABLED: "This operation is prepared but not enabled yet.",
70 DISKS_REBOOT_REQUIRED: "The kernel still uses the old partition table. Reboot (or finish in a rescue system) before continuing.",
71 DISKS_PRIVILEGE_REQUIRED: "This needs root or sudo.",
72 DISKS_PLATFORM_UNSUPPORTED: "Disk management is available only for Linux servers.",
73 DISKS_SYSTEM_MOUNT: "This mount point is needed by the running system; CH-J never unmounts it.",
74 DISKS_LV_THIN_UNSUPPORTED: "Thin pools and thin volumes are shown read-only.",
75 DISKS_SUGGESTION_GONE: "The expansion is no longer possible with the current layout.",
76 DISKS_SYSTEM_PARTITION: "Boot partition of the disk that runs the system."
77};
78function describe(error) {
79 const { code, detail } = parseError(error);
80 const key = ERROR_KEYS[code];
81 return key ? `${t(key)}${detail && !detail.startsWith(code) ? ` (${detail})` : ""}` : detail || code;
83const errorCode = (error) => parseError(error).code;
85// ---------- sudo ----------
86function askSudo(message) {
87 return new Promise((resolve) => {
88 el.sudoMessage.textContent = message || t("Reading partition tables, LVM and LUKS details and all changes need root rights.");
89 el.sudoPassword.value = ""; el.sudoError.hidden = true;
90 const finish = (value) => { el.sudoForm.removeEventListener("submit", submit); el.sudoDialog.removeEventListener("close", cancel); if (el.sudoDialog.open) el.sudoDialog.close(); resolve(value); };
91 const submit = (event) => { event.preventDefault(); const password = el.sudoPassword.value; el.sudoPassword.value = ""; finish({ sudoPassword: password, rememberSudo: el.sudoRemember.checked }); };
92 const cancel = () => finish(null);
93 el.sudoForm.addEventListener("submit", submit);
94 el.sudoDialog.addEventListener("close", cancel);
95 el.sudoDialog.showModal(); el.sudoPassword.focus();
96 });
98// Runs fn with no password first (root, NOPASSWD or a remembered password); asks only when sudo requires it.
99async function withSudo(fn, message) {
100 try { return await fn({}); }
101 catch (error) {
102 const code = errorCode(error);
103 if (!["SUDO_PASSWORD_REQUIRED", "SUDO_PASSWORD_INCORRECT"].includes(code)) throw error;
104 for (let attempt = 0; attempt < 3; attempt += 1) {
105 const credentials = await askSudo(attempt ? t("The sudo password was rejected.") : message);
106 if (!credentials) throw new Error("CHJ_CANCELLED: cancelled");
107 try { return await fn(credentials); }
108 catch (retry) { if (errorCode(retry) !== "SUDO_PASSWORD_INCORRECT") throw retry; }
109 }
110 throw new Error("SUDO_PASSWORD_INCORRECT: rejected");
111 }
113const cancelled = (error) => errorCode(error) === "CHJ_CANCELLED";
115// ---------- sessions ----------
116async function loadSessions() {
117 let sessions = [];
118 try { sessions = (await api.sessions.list()).filter((session) => session.state === "connected"); }
119 catch (error) { setStatus(t("Sessions cannot be loaded: {error}", { error: describe(error) }), "error"); }
120 state.sessions = sessions;
121 const identityOf = (session) => session ? `${session.profileId}|${session.host}|${session.port}|${session.username}` : "";
122 // Prefer the session of the server shown now (also after a reconnect under a new session ID).
123 const current = sessions.find((session) => session.sessionId === state.sessionId && identityOf(session) === state.identity)
124 || (state.identity && sessions.find((session) => identityOf(session) === state.identity))
125 || (!state.identity && sessions[0]) || null;
126 el.sessionSelect.replaceChildren(new Option(sessions.length ? t("Select a session…") : t("No connected session"), ""));
127 for (const session of sessions) el.sessionSelect.add(new Option(`${session.label} — ${session.username}@${session.host}`, session.sessionId));
128 el.sessionSelect.value = current?.sessionId || "";
129 if (!current) {
130 if (state.identity && !state.disconnected) {
131 // Same server, connection lost: keep the view marked as stale; a running job continues on the server.
132 state.disconnected = true; stopJobPolling();
133 if (state.job) state.job.disconnected = true;
134 setStatus(t("The SSH connection was lost. The view shows the last known state; it is refreshed after reconnecting."), "warn");
135 renderJob(); updateButtons();
136 } else if (!state.identity) { setStatus(t("Connect to a server over SSH in the Core first.")); render(); }
137 return;
138 }
139 const identity = identityOf(current);
140 if (identity !== state.identity) {
141 resetServer();
142 Object.assign(state, { identity, sessionId: current.sessionId, disconnected: false });
143 await loadInventory();
144 } else if (state.disconnected || current.sessionId !== state.sessionId) {
145 Object.assign(state, { sessionId: current.sessionId, disconnected: false });
146 setStatus(t("Reconnected. Reading the current state…"), "busy");
147 await loadInventory({ privileged: Boolean(state.inventory?.privileged) });
148 if (state.job) { state.job.disconnected = false; pollJob(); }
149 }
151// Data of one server is never shown as the state of another one.
152function resetServer() {
153 state.generation += 1;
154 stopJobPolling();
155 if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {});
156 Object.assign(state, { inventory: null, selectedDisk: null, selected: null, queue: [], preview: null, job: null });
157 el.jobPanel.hidden = true;
158 render();
161// ---------- inventory ----------
162async function loadInventory({ privileged = false } = {}) {
163 const sessionId = state.sessionId; if (!sessionId) return;
164 const token = state.generation;
165 state.busy = true; setStatus(t("Reading disks…"), "busy"); updateButtons();
166 try {
167 const call = (credentials) => api.disks.inspect(sessionId, { privileged, ...credentials });
168 const inventory = privileged ? await withSudo(call) : await call({});
169 if (token !== state.generation) return;
170 state.inventory = inventory;
171 if (!inventory.supported) { render(); setStatus(""); return; }
172 if (!inventory.disks.some((disk) => disk.name === state.selectedDisk)) state.selectedDisk = inventory.disks.find((disk) => disk.system)?.name || inventory.disks[0]?.name || null;
173 state.selected = null;
174 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");
175 render();
176 if (inventory.privileged && !state.job) checkJobs(true).catch(() => {});
177 } catch (error) {
178 if (token !== state.generation) return;
179 if (!cancelled(error)) setStatus(t("Disks cannot be read: {error}", { error: describe(error) }), "error");
180 } finally { if (token === state.generation) { state.busy = false; updateButtons(); renderSuggestions(); } }
183function currentDisk() { return state.inventory?.disks?.find((disk) => disk.name === state.selectedDisk) || null; }
184function nodeByPath(path) { return state.inventory?.nodes?.find((node) => node.path === path || node.displayPath === path || `/dev/${node.kname}` === path) || null; }
185function diskByPath(path) { return state.inventory?.disks?.find((disk) => disk.path === path) || null; }
187// View of a disk: the inventory, or the simulated result of the queued operations (preview).
188function diskView(disk) {
189 const preview = state.preview?.after?.find((entry) => entry.name === disk.name);
190 const segments = (preview?.segments || disk.segments).map((segment) => {
191 if (segment.kind !== "partition") return { ...segment };
192 const part = disk.partitions.find((entry) => entry.number === segment.number && entry.start === segment.start && !segment.created);
193 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 || [] };
194 });
195 return { disk, segments, preview: Boolean(preview), table: preview ? preview.table : disk.table };
198// ---------- rendering ----------
199const 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" };
200const modelText = (disk) => (disk.model && disk.vendor && disk.model.startsWith(disk.vendor) ? disk.model : [disk.vendor, disk.model].filter(Boolean).join(" ")) || "—";
201const LEGEND = [["ext4", "EXT4"], ["xfs", "XFS"], ["btrfs", "Btrfs"], ["fat", "FAT32"], ["ntfs", "NTFS"], ["swap", "Linux swap"], ["lvm", "LVM"], ["luks", "LUKS"], ["free", "Unallocated"], ["unknown", "Unknown"]];
202function fsClass(fstype, kind) { return kind === "free" ? "free" : FS_CLASS[fstype] || (fstype ? "unknown" : "none"); }
203function 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"); }
204function 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; }
206function render() {
207 const inventory = state.inventory;
208 el.unsupported.hidden = !inventory || inventory.supported;
209 if (inventory && !inventory.supported) {
210 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 }) }));
211 }
212 el.workspace.hidden = !inventory?.supported;
213 el.privilegeBadge.textContent = !inventory?.supported ? "" : inventory.privileged ? t("full inventory (root)") : t("read-only, partial");
214 el.privilegeBadge.className = `pill ${inventory?.privileged ? "ok" : "warn"}`;
215 renderSuggestions();
216 if (!inventory?.supported) { renderQueue(); updateButtons(); return; }
217 renderDiskList(); renderDisk(); renderLvm(); renderQueue(); updateButtons();
220function renderDiskList() {
221 el.diskList.replaceChildren(...state.inventory.disks.map((disk) => h("li", {
222 role: "option", "aria-selected": String(disk.name === state.selectedDisk), tabindex: 0, className: disk.name === state.selectedDisk ? "active" : "",
223 onclick: () => { state.selectedDisk = disk.name; state.selected = null; render(); },
224 onkeydown: (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); state.selectedDisk = disk.name; render(); } }
225 }, [
226 h("strong", { text: disk.path }), h("span", { text: `${bytes(disk.size)} · ${mediaText(disk)}` }),
227 h("small", { text: modelText(disk) }),
228 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" })])
229 ])));
230 if (!state.inventory.disks.length) el.diskList.append(h("li", { className: "empty", text: t("No block devices found.") }));
233function fact(label, value) { return h("div", {}, [h("dt", { text: label }), h("dd", { text: value ?? "—" })]); }
234function renderDisk() {
235 const disk = currentDisk();
236 el.diskFacts.replaceChildren(); el.diskMap.replaceChildren(); el.partitionRows.replaceChildren(); el.diskBadges.replaceChildren();
237 if (!disk) { el.diskTitle.textContent = "—"; return; }
238 const view = diskView(disk);
239 el.diskTitle.textContent = `${disk.path} — ${bytes(disk.size)}`;
240 el.previewBadge.hidden = !view.preview;
241 el.diskBadges.replaceChildren(...[
242 h("i", { className: "pill", text: mediaText(disk) }), disk.transport && h("i", { className: "pill", text: disk.transport }),
243 disk.system && h("i", { className: "pill warn", text: t("contains the running system") }),
244 disk.protected && h("i", { className: "pill danger", text: t("Proxmox host storage — protected") }),
245 disk.readOnly && h("i", { className: "pill danger", text: t("read-only") })
246 ].filter(Boolean));
247 const table = disk.table;
248 el.diskFacts.append(...[
249 fact(t("Model"), modelText(disk)), fact(t("Serial / WWN"), [disk.serial, disk.wwid].filter(Boolean).join(" · ") || "—"),
250 fact(t("Partition table"), table ? `${table.type === "dos" ? "MBR (dos)" : table.type.toUpperCase()}${table.source === "sysfs" ? ` (${t("partial, without root")})` : ""}` : t("none")),
251 fact(t("Sectors"), `${t("logical")} ${disk.logicalSectorSize} B / ${t("physical")} ${disk.physicalSectorSize} B · ${disk.sectors.toLocaleString(locale())}`),
252 fact(t("Layers"), Object.entries(disk.layers || {}).filter(([, on]) => on).map(([name]) => name.toUpperCase()).join(", ") || t("none")),
253 disk.loopBacking && fact(t("Image file"), disk.loopBacking),
254 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.")),
255 table?.mbrLimited && fact(t("MBR"), t("MBR can address only the first 2 TiB of this disk."))
256 ].filter(Boolean));
257 renderMap(view);
258 renderRows(view);
261function selectSegment(segment) {
262 state.selected = segment.kind === "free" ? { kind: "free", start: segment.start } : segment.kind === "whole" ? { kind: "whole" } : { kind: "partition", number: segment.number };
263 renderDisk(); showDetails(segment);
265function isSelected(segment) {
266 const selected = state.selected;
267 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));
270function renderMap(view) {
271 const disk = view.disk;
272 let segments = view.segments;
273 if (!view.table) {
274 const node = nodeByPath(disk.path);
275 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 }];
276 }
277 const total = segments.reduce((sum, segment) => sum + segment.bytes, 0) || 1;
278 for (const segment of segments) {
279 const part = segment.part;
280 const usage = part?.usage || segment.node?.usage || null;
281 const used = usage ? pct(usage.used, usage.size) : null;
282 const cls = fsClass(segment.fstype, segment.kind);
283 const block = h("div", {
284 className: `segment fs-${cls}${isSelected(segment) ? " selected" : ""}${segment.created ? " created" : ""}`,
285 style: { flexGrow: String(segment.bytes / total * 1000) }, tabindex: 0, role: "button",
286 title: `${segment.kind === "free" ? t("Unallocated") : segment.path || ""} · ${fsName(segment.fstype)} · ${bytes(segment.bytes)}`,
287 onclick: () => selectSegment(segment),
288 oncontextmenu: (event) => { event.preventDefault(); selectSegment(segment); openContextMenu(event.clientX, event.clientY, segment, view); },
289 onkeydown: (event) => {
290 if (event.key === "Enter") selectSegment(segment);
291 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); }
292 }
293 }, [
294 used !== null && h("div", { className: "used", style: { width: `${used}%` } }),
295 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)}` })])
296 ]);
297 if (segment.kind === "partition" && isSelected(segment) && !view.preview) attachGrip(block, segment, view, total);
298 el.diskMap.append(block);
299 }
300 el.legend.replaceChildren(...LEGEND.map(([cls, name]) => h("li", {}, [h("b", { className: `fs-${cls}` }), name === "Unallocated" || name === "Unknown" ? t(name) : name])));
303// Drag the right boundary of the selected partition; the exact value is confirmed in the resize dialog.
304function attachGrip(block, segment, view, total) {
305 const limits = resizeLimits(view, segment);
306 if (!limits.allowed) return;
307 const grip = h("span", { className: "grip", title: t("Drag to resize"), "aria-hidden": "true" });
308 grip.addEventListener("mousedown", (event) => {
309 event.preventDefault(); event.stopPropagation();
310 const mapBox = el.diskMap.getBoundingClientRect(); const box = block.getBoundingClientRect();
311 const bytesPerPixel = total / mapBox.width;
312 const ghost = h("div", { className: "drag-ghost" }); el.diskMap.append(ghost);
313 let size = segment.size;
314 const move = (moveEvent) => {
315 const right = Math.max(box.left + 4, Math.min(moveEvent.clientX, mapBox.right));
316 const newBytes = (right - box.left) * bytesPerPixel;
317 size = Math.max(limits.minSectors, Math.min(limits.maxSectors, Math.round(newBytes / view.disk.logicalSectorSize)));
318 Object.assign(ghost.style, { left: `${box.left - mapBox.left}px`, width: `${size * view.disk.logicalSectorSize / bytesPerPixel}px` });
319 ghost.textContent = bytes(size * view.disk.logicalSectorSize);
320 };
321 const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); ghost.remove(); if (size !== segment.size) openResize(segment, view, size); };
322 document.addEventListener("mousemove", move); document.addEventListener("mouseup", up);
323 });
324 block.append(grip);
327function flagsOf(part) {
328 if (!part) return [];
329 const flags = [];
330 if (part.bootable) flags.push("boot");
331 if (/LegacyBIOSBootable/.test(part.attrs || "")) flags.push("legacy_boot");
332 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);
333 return flags;
335function statusText(part, node) {
336 if (!part && !node) return "";
337 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));
338 return reasons.join("; ") || t("not in use");
341function renderRows(view) {
342 const disk = view.disk;
343 const rows = [];
344 const pushNodeTree = (kname, depth) => {
345 const node = state.inventory.nodes.find((entry) => entry.kname === kname);
346 if (!node) return;
347 const name = node.displayPath || node.path;
348 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;
349 rows.push(h("tr", { className: "layer", onclick: () => showNodeDetails(node) }, [
350 h("td", { style: { paddingLeft: `${8 + depth * 18}px` } }, [h("span", { className: "tree", text: "↳ " }), name]),
351 h("td", {}, [h("i", { className: `swatch fs-${fsClass(node.fstype)}` }), `${fsName(node.fstype)} · ${kind}`]),
352 h("td", { text: node.mountpoints.join(", ") || (node.swapActive ? "[SWAP]" : "") }), h("td", { text: node.label || "" }),
353 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) : "—" }),
354 h("td", { text: "" }), h("td", { text: statusText(null, node) })
355 ]));
356 for (const child of node.children || []) pushNodeTree(child, depth + 1);
357 };
358 const segments = view.table ? view.segments : [];
359 for (const segment of segments) {
360 const part = segment.part;
361 const usage = part?.usage;
362 rows.push(h("tr", { className: isSelected(segment) ? "selected" : "", onclick: () => selectSegment(segment), oncontextmenu: (event) => { event.preventDefault(); selectSegment(segment); openContextMenu(event.clientX, event.clientY, segment, view); } }, [
363 h("td", { text: segment.kind === "free" ? t("unallocated") : `${segment.path || `#${segment.number}`}${segment.created ? ` (${t("new")})` : ""}` }),
364 h("td", {}, [h("i", { className: `swatch fs-${fsClass(segment.fstype, segment.kind)}` }), segment.kind === "free" ? "" : fsName(segment.fstype)]),
365 h("td", { text: segment.mountpoints?.join(", ") || "" }),
366 h("td", { text: [segment.label, part?.name ? `«${part.name}»` : ""].filter(Boolean).join(" ") }),
367 h("td", { text: bytes(segment.bytes) }),
368 h("td", { text: usage ? bytes(usage.used) : "—" }), h("td", { text: usage ? bytes(usage.size - usage.used) : "—" }),
369 h("td", { text: flagsOf(part).join(", ") }), h("td", { text: segment.kind === "free" ? "" : statusText(part) })
370 ]));
371 if (part?.kernel && !view.preview) for (const child of part.children || []) pushNodeTree(child, 1);
372 }
373 if (!view.table) {
374 const node = nodeByPath(disk.path);
375 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); }
376 }
377 el.partitionRows.replaceChildren(...rows);
380function renderLvm() {
381 const lvm = state.inventory.lvm;
382 el.lvmPanel.hidden = !lvm?.vgs?.length;
383 if (el.lvmPanel.hidden) return;
384 const rows = [];
385 for (const vg of lvm.vgs) {
386 const pvs = lvm.pvs.filter((pv) => pv.vg === vg.name).map((pv) => pv.path).join(", ");
387 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: "" })]));
388 for (const lv of lvm.lvs.filter((entry) => entry.vg === vg.name)) {
389 const node = nodeByPath(lv.dmPath) || nodeByPath(lv.path);
390 const type = lv.thinPool ? t("thin pool") : lv.thinVolume ? t("thin volume (pool {pool})", { pool: lv.pool }) : lv.snapshot ? t("snapshot") : lv.segtype || "linear";
391 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) });
392 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) });
393 rows.push(h("tr", { onclick: (event) => { if (event.target.tagName !== "BUTTON" && node) showNodeDetails(node); } }, [
394 h("td", { style: { paddingLeft: "24px" }, text: `${lv.name}${lv.pveGuest ? ` (${t("Proxmox guest {id}", { id: lv.pveGuest })})` : ""}` }), h("td", { text: bytes(lv.size) }),
395 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 }) : "" }),
396 h("td", { text: type }), h("td", { text: node ? `${fsName(node.fstype)} ${node.mountpoints.join(", ")}` : lv.active ? "" : t("inactive") }),
397 h("td", { className: "actions-cell" }, [extend, reduce])
398 ]));
399 }
400 }
401 el.lvmRows.replaceChildren(...rows);
404function renderSuggestions() {
405 const list = state.inventory?.supported ? state.inventory.suggestions || [] : [];
406 el.suggestions.replaceChildren(...list.map((suggestion) => {
407 const head = suggestion.kind === "partition" ? t("Found {free} of unallocated space. Partition {target} can be expanded.", { free: bytes(suggestion.freeBytes), target: suggestion.target })
408 : suggestion.kind === "filesystem" ? t("The file system on {target} is {free} smaller than its device.", { free: bytes(suggestion.freeBytes), target: suggestion.target })
409 : suggestion.kind === "pv" ? t("The LVM physical volume {target} can grow by {free}.", { free: bytes(suggestion.freeBytes), target: suggestion.target })
410 : t("Volume group free space ({free}) can extend {target}.", { free: bytes(suggestion.freeBytes), target: suggestion.target });
411 return h("article", { className: `suggestion ${suggestion.available ? "ok" : "blocked"}` }, [
412 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.")}` : "")
413 : t("Automatic expansion is not possible: {reason}", { reason: describe(new Error(`${suggestion.blocked?.code}: ${suggestion.blocked?.message}`)) }) })]),
414 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) })
415 ]);
416 }));
419function renderQueue() {
420 el.queuePanel.hidden = !state.queue.length;
421 el.queueCount.textContent = String(state.queue.length);
422 el.queueList.replaceChildren(...state.queue.map((op) => h("li", { text: describeOperation(op) })));
425function updateButtons() {
426 const ready = Boolean(state.sessionId) && !state.busy && !state.disconnected;
427 el.refreshButton.disabled = !ready;
428 el.privilegedButton.disabled = !ready || !state.inventory?.supported || state.inventory?.privileged || state.inventory?.privilege?.mode === "none";
429 el.privilegedButton.hidden = Boolean(state.inventory?.privileged);
430 el.jobsButton.disabled = !ready || !state.inventory?.supported || state.inventory?.privilege?.mode === "none";
431 el.smartButton.disabled = !ready || !currentDisk() || currentDisk()?.kind === "loop";
432 el.tableButton.disabled = !ready || !manageable() || !currentDisk() || Boolean(currentDisk()?.protected);
433 el.queueApply.disabled = !ready || !state.queue.length || Boolean(state.job && state.job.outcome === "running");
435const manageable = () => Boolean(state.inventory?.supported && state.inventory.privilege?.mode !== "none");
437// ---------- details ----------
438function showDetails(segment) {
439 const disk = currentDisk(); if (!disk) return;
440 const part = segment.part;
441 if (segment.kind === "free") {
442 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.")]]);
443 return;
444 }
445 if (segment.kind === "whole") { const node = segment.node; if (node) showNodeDetails(node); return; }
446 if (!part) { showFacts(segment.path || "", [[t("Size"), bytes(segment.bytes)], [t("Note"), t("Partition planned in the queue.")]]); return; }
447 const node = state.inventory.nodes.find((entry) => entry.kname === part.kernel);
448 showFacts(part.path || `#${part.number}`, [
449 [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)`],
450 [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],
451 [t("File system"), fsName(part.fstype)], [t("File system UUID"), part.fsUuid], [t("Label"), part.label], node?.fsVersion && [t("Version"), node.fsVersion],
452 [t("File system size"), part.fsSize ? bytes(part.fsSize) : "—"], [t("Mount points"), part.mountpoints.join(", ") || "—"],
453 part.usage && [t("Usage"), `${bytes(part.usage.used)} / ${bytes(part.usage.size)} (${part.usage.source === "df" ? t("mounted, df") : t("superblock")})`],
454 [t("Status"), statusText(part)], part.systemRoles.length && [t("System roles"), part.systemRoles.join(", ")],
455 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).")]
456 ]);
458function showNodeDetails(node) {
459 showFacts(node.displayPath || node.path, [
460 [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],
461 [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)],
462 node.lvm && [t("LVM"), `${node.lvm.vg}/${node.lvm.name} · ${node.lvm.segtype || ""} ${node.lvm.thinVolume ? `thin (pool ${node.lvm.pool})` : ""}`],
463 node.crypt && [t("LUKS"), `${node.crypt.type || ""} · ${node.crypt.cipher || ""} · ${t("key location")}: ${node.crypt.keyLocation || "—"}`],
464 node.pv && [t("LVM PV"), `VG ${node.pv.vg || "—"} · ${bytes(node.pv.size)} (${t("free")} ${bytes(node.pv.free)})`],
465 node.btrfs && [t("Btrfs"), `devid ${node.btrfs.devid} / ${node.btrfs.devices} · ${t("allocated")} ${bytes(node.btrfs.allocated)}`],
466 node.zfsPool && [t("ZFS pool"), node.zfsPool], node.loopBacking && [t("Image file"), node.loopBacking],
467 [t("Built from"), (node.parents || []).join(", ") || "—"], [t("Used by"), (node.children || []).join(", ") || "—"], [t("Status"), statusText(null, node)]
468 ]);
470function showFacts(title, entries) {
471 el.detailTitle.textContent = title;
472 el.detailFacts.replaceChildren(...entries.filter((entry) => entry && entry[1] !== null && entry[1] !== undefined && entry[1] !== "").map(([label, value]) => fact(label, String(value))));
473 el.detailPanel.hidden = false;
476// ---------- capability checks (mirror Core rules; Core validates again) ----------
477const SHRINKABLE = new Set(["ext2", "ext3", "ext4", "btrfs", "ntfs", "swap"]);
478const GROWABLE = new Set(["ext2", "ext3", "ext4", "xfs", "btrfs", "ntfs", "swap", "crypto_LUKS", "LVM2_member"]);
479const tool = (name) => Boolean(state.inventory?.tools?.[name]);
480function resizeLimits(view, segment) {
481 const disk = view.disk; const part = segment.part;
482 const lbs = disk.logicalSectorSize; const align = disk.alignSectors;
483 if (!part || !manageable() || !view.table || part.extended || part.logical) return { allowed: false, reason: t("Not available for this partition.") };
484 if (!tool("sfdisk")) return { allowed: false, reason: t("sfdisk is not installed on the server.") };
485 const index = view.segments.indexOf(segment);
486 const next = view.segments[index + 1];
487 const maxSectors = segment.size + (next?.kind === "free" ? next.size : 0);
488 const fstype = part.fstype;
489 const node = state.inventory.nodes.find((entry) => entry.kname === part.kernel);
490 // A queued unmount makes the partition free in the simulated layout.
491 const inUse = part.inUse && !state.queue.some((op) => op.type === "unmount" && op.device === part.path);
492 let minSectors = segment.size; let shrinkReason = "";
493 if (!fstype) minSectors = align;
494 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) });
495 else if (disk.protected) shrinkReason = t("Proxmox host storage — protected");
496 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).");
497 else if (fstype === "swap" && node?.swapActive) shrinkReason = t("Active swap is never disabled automatically.");
498 else if (fstype === "ntfs" && inUse) shrinkReason = t("Unmount the file system first (queue an unmount operation).");
499 else {
500 const used = fstype === "btrfs" ? node?.btrfs?.allocated ?? part.usage?.used : part.usage?.used;
501 const reserve = fstype === "btrfs" ? 256 * MiB : 128 * MiB;
502 minSectors = fstype === "swap" ? align : known(used) ? Math.ceil((used + Math.max(used / 10, reserve)) / lbs / align) * align : segment.size;
503 if (!known(used) && fstype !== "swap") shrinkReason = t("The usage is unknown, so the safe minimum cannot be computed.");
504 }
505 const canGrow = maxSectors > segment.size && (!fstype || GROWABLE.has(fstype));
506 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.") };
509function menuItems(segment, view) {
510 const disk = view.disk; const part = segment.part;
511 const items = [];
512 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 });
513 if (segment.kind === "free") {
514 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));
515 return items;
516 }
517 items.push({ label: t("Details"), enabled: true, action: () => showDetails(segment) });
518 if (segment.kind === "whole") {
519 const node = segment.node;
520 add(t("Format…"), node && !node.inUse && !disk.protected, t("The device is in use."), () => openFormat(node.path, node));
521 return items;
522 }
523 if (!part) return items;
524 const node = state.inventory.nodes.find((entry) => entry.kname === part.kernel);
525 const limits = resizeLimits(view, segment);
526 const bootPart = disk.system && ["bios-boot", "esp"].includes(part.typeName);
527 const editable = !view.preview || !segment.created;
528 add(t("Resize…"), limits.allowed && editable, limits.growReason || limits.shrinkReason || limits.reason, () => openResize(segment, view));
529 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 });
530 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 })));
531 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));
532 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));
533 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 }));
534 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));
535 add(t("Partition name…"), view.table?.type === "gpt", t("Partition names exist only in GPT."), () => openName(disk, part));
536 add(t("Flags…"), !part.extended && !part.logical, t("Extended and logical MBR partitions are read-only."), () => openFlags(disk, part));
537 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 }));
538 else add(t("Mount…"), ["ext2", "ext3", "ext4", "xfs", "btrfs", "vfat", "ntfs"].includes(part.fstype), t("No mountable file system."), () => openMount(part.path));
539 return items;
541function openContextMenu(x, y, segment, view) {
542 const menu = el.contextMenu;
543 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])));
544 menu.hidden = false;
545 const box = menu.getBoundingClientRect();
546 menu.style.left = `${Math.min(x, window.innerWidth - box.width - 8)}px`; menu.style.top = `${Math.min(y, window.innerHeight - box.height - 8)}px`;
547 menu.querySelector("button:not([disabled])")?.focus();
549function closeContextMenu() { el.contextMenu.hidden = true; }
551// ---------- forms ----------
552function unitInput(id, sectors, lbs) {
553 const valueBytes = sectors * lbs;
554 const unit = valueBytes >= 1024 ** 4 ? "TiB" : valueBytes >= 1024 ** 3 ? "GiB" : "MiB";
555 const factor = { MiB, GiB: 1024 ** 3, TiB: 1024 ** 4 }[unit];
556 return h("span", { className: "unit-input" }, [
557 h("input", { id, type: "number", min: "0", step: "any", value: String(Math.floor(valueBytes / factor * 1000) / 1000) }),
558 h("select", { id: `${id}Unit` }, ["MiB", "GiB", "TiB"].map((name) => h("option", { value: name, selected: name === unit, text: name })))
559 ]);
561function readUnit(id, lbs) {
562 const value = Number(document.getElementById(id).value);
563 const factor = { MiB, GiB: 1024 ** 3, TiB: 1024 ** 4 }[document.getElementById(`${id}Unit`).value];
564 return Number.isFinite(value) && value > 0 ? Math.floor(value * factor / lbs) : NaN;
566function field(label, control, hint) { return h("label", {}, [h("span", { text: label }), control, hint && h("small", { className: "hint", text: hint })]); }
567function fsOptions(selected = "ext4", includeNone = false) {
568 const tools = { ext4: "mkfs.ext4", xfs: "mkfs.xfs", btrfs: "mkfs.btrfs", vfat: "mkfs.vfat", ntfs: "mkfs.ntfs", swap: "mkswap" };
569 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 })}`}` }));
570 if (includeNone) options.unshift(h("option", { value: "none", text: t("unformatted") }));
571 return options;
573let formHandler = null;
574function openForm(title, body, onSubmit, submitText) {
575 el.formTitle.textContent = title;
576 el.formBody.replaceChildren(...[].concat(body));
577 el.formError.hidden = true;
578 el.formSubmit.textContent = submitText || t("Add to queue");
579 formHandler = onSubmit;
580 el.formDialog.showModal();
581 el.formBody.querySelector("input,select")?.focus();
583function formError(message) { el.formError.textContent = message; el.formError.hidden = false; }
585function miniMap(view, highlight) {
586 if (!view.segments?.length) {
587 const node = nodeByPath(view.path);
588 view = { ...view, segments: [{ kind: node?.fstype ? "whole" : "free", bytes: view.size || 1, fstype: node?.fstype || null, path: view.path }] };
589 }
590 const total = view.segments.reduce((sum, segment) => sum + segment.bytes, 0) || 1;
591 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/", "") })])])));
594function openResize(segment, view, proposed) {
595 const disk = view.disk; const part = segment.part; const lbs = disk.logicalSectorSize;
596 const limits = resizeLimits(view, segment);
597 const target = proposed || segment.size;
598 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") });
599 const input = unitInput("resizeSize", target, lbs);
600 const summary = h("p", { className: "hint" });
601 const preview = h("div");
602 const update = (sectors) => {
603 const after = { ...view, segments: view.segments.flatMap((entry, index) => {
604 if (entry === segment) return [{ ...entry, size: sectors, bytes: sectors * lbs }];
605 const previous = view.segments[index - 1];
606 if (previous === segment && entry.kind === "free") { const rest = entry.size - (sectors - segment.size); return rest * lbs >= MiB ? [{ ...entry, size: rest, bytes: rest * lbs }] : []; }
607 return [entry];
608 }) };
609 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 });
610 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));
611 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) });
612 };
613 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); });
614 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))); } });
615 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")); } });
616 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}` }),
617 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.") }),
618 /^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.") })];
619 openForm(t("Resize {path}", { path: part.path }), [preview, range, field(t("New size"), input), max, summary, ...notes.filter(Boolean)], () => {
620 let size = readUnit("resizeSize", lbs);
621 if (Number(range.value) === limits.maxSectors && Math.abs(size - limits.maxSectors) * lbs < MiB) size = limits.maxSectors;
622 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) }));
623 return { type: "resize-partition", disk: disk.name, number: part.number, size: size === limits.maxSectors ? "max" : size };
624 });
625 update(target);
628function openCreate(segment, view) {
629 const disk = view.disk; const lbs = disk.logicalSectorSize;
630 const gpt = view.table?.type === "gpt";
631 const body = [
632 h("p", { className: "hint", text: t("Unallocated space: {size} (sectors {start}–{end}).", { size: bytes(segment.bytes), start: segment.start, end: segment.end }) }),
633 field(t("Size"), unitInput("createSize", segment.size, lbs), t("Aligned to 1 MiB. The maximum uses the whole free area.")),
634 field(t("File system"), h("select", { id: "createFs" }, fsOptions("ext4", true))),
635 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.")),
636 gpt && field(t("Partition name (GPT)"), h("input", { id: "createName", maxlength: "36" })),
637 h("label", { className: "check" }, [h("input", { id: "createEsp", type: "checkbox" }), t("EFI system partition (FAT32 only)")])
638 ];
639 openForm(t("New partition on {disk}", { disk: disk.path }), body.filter(Boolean), () => {
640 let size = readUnit("createSize", lbs);
641 if (!Number.isFinite(size)) return formError(t("Enter a valid size."));
642 const fs = document.getElementById("createFs").value;
643 if (Math.abs(size - segment.size) * lbs < MiB || size > segment.size) size = "max";
644 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 };
645 });
647function openFormat(path, node) {
648 openForm(t("Format {path}", { path }), [
649 h("p", { className: "hint warn", text: t("Formatting destroys all data on {path}.", { path }) }),
650 field(t("File system"), h("select", { id: "formatFs" }, fsOptions(node?.fstype && node.fstype !== "LVM2_member" ? node.fstype : "ext4"))),
651 field(t("Label"), h("input", { id: "formatLabel", maxlength: "16" }))
652 ], () => ({ type: "format", device: path, fs: document.getElementById("formatFs").value, label: document.getElementById("formatLabel").value.trim() || undefined }));
654function openCheck(path, fstype) {
655 openForm(t("Check {path}", { path }), [
656 h("label", { className: "check" }, [h("input", { type: "radio", name: "checkMode", value: "check", checked: true }), t("Read-only check (no changes)")]),
657 h("label", { className: "check" }, [h("input", { type: "radio", name: "checkMode", value: "repair", disabled: fstype === "btrfs" }), t("Check and repair automatically safe problems")]),
658 fstype === "btrfs" && h("p", { className: "hint", text: t("btrfs check --repair is not offered: it can make damage worse.") })
659 ].filter(Boolean), () => ({ type: "check", device: path, repair: document.querySelector("input[name=checkMode]:checked")?.value === "repair" }));
661function openLabel(path, fstype, current) {
662 const limit = { ext2: 16, ext3: 16, ext4: 16, xfs: 12, btrfs: 36, vfat: 11, ntfs: 32, swap: 15 }[fstype] || 16;
663 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),
664 () => ({ type: "set-label", device: path, label: document.getElementById("labelValue").value.trim() || undefined }));
666function openName(disk, part) {
667 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."))],
668 () => ({ type: "set-name", disk: disk.name, number: part.number, name: document.getElementById("nameValue").value.trim() || undefined }));
670function openFlags(disk, part) {
671 const gpt = disk.table?.type === "gpt";
672 const flags = gpt ? ["legacy_boot", "esp", "bios-boot", "lvm", "raid", "swap", "basic-data"] : ["boot", "esp", "lvm", "raid", "swap"];
673 const current = new Set(flagsOf(part));
674 const exclusive = flags.filter((flag) => !["boot", "legacy_boot"].includes(flag));
675 openForm(t("Flags of {path}", { path: part.path }), [
676 ...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])),
677 h("label", { className: "check" }, [h("input", { type: "radio", name: "flagType", value: "", checked: !exclusive.some((flag) => current.has(flag)) }), t("no type flag (Linux data)")]),
678 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.") })
679 ], () => {
680 const ops = [];
681 for (const flag of ["boot", "legacy_boot"].filter((name) => flags.includes(name))) {
682 const on = document.querySelector(`input[name=flag-${flag}]`).checked;
683 if (on !== current.has(flag)) ops.push({ type: "set-flag", disk: disk.name, number: part.number, flag, enabled: on });
684 }
685 const selected = document.querySelector("input[name=flagType]:checked")?.value || "";
686 const active = exclusive.find((flag) => current.has(flag)) || "";
687 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 });
688 if (!ops.length) return formError(t("Nothing changed."));
689 return ops;
690 });
692function openMount(path) {
693 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."))],
694 () => ({ type: "mount", device: path, mountpoint: document.getElementById("mountPoint").value.trim() }));
696function openTable() {
697 const disk = currentDisk(); if (!disk) return;
698 openForm(t("New partition table on {disk}", { disk: disk.path }), [
699 h("p", { className: "hint warn", text: t("A new partition table removes ALL partitions and makes all data on {disk} inaccessible.", { disk: disk.path }) }),
700 h("label", { className: "check" }, [h("input", { type: "radio", name: "tableType", value: "gpt", checked: true }), "GPT"]),
701 h("label", { className: "check" }, [h("input", { type: "radio", name: "tableType", value: "dos" }), t("MBR (dos) — max. 4 primary partitions, 2 TiB")])
702 ], () => ({ type: "create-table", disk: disk.name, label: document.querySelector("input[name=tableType]:checked").value }));
704function openLvExtend(lv, vg) {
705 openForm(t("Extend {lv}", { lv: `${lv.vg}/${lv.name}` }), [
706 h("p", { className: "hint", text: t("Volume group {vg} has {free} free.", { vg: vg.name, free: bytes(vg.free) }) }),
707 h("label", { className: "check" }, [h("input", { id: "lvMax", type: "checkbox", checked: true }), t("Use all free space of the volume group")]),
708 field(t("New total size"), unitInput("lvSize", Math.floor((lv.size + vg.free) / 512), 512)),
709 h("p", { className: "hint", text: t("The file system inside is grown in a separate step (ext4 online/offline, XFS and Btrfs online).") })
710 ], () => {
711 if (document.getElementById("lvMax").checked) return { type: "lv-extend", lv: `${lv.vg}/${lv.name}`, size: "max" };
712 const size = readUnit("lvSize", 512) * 512;
713 if (!Number.isFinite(size) || size <= lv.size) return formError(t("The new size must be larger than the current size."));
714 return { type: "lv-extend", lv: `${lv.vg}/${lv.name}`, size };
715 });
717function openLvReduce(lv, vg, node) {
718 const used = node?.usage?.used;
719 openForm(t("Reduce {lv} (advanced)", { lv: `${lv.vg}/${lv.name}` }), [
720 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.") }),
721 field(t("New size"), unitInput("lvReduce", Math.floor(lv.size / 512), 512), known(used) ? t("Used: {used}", { used: bytes(used) }) : ""),
722 h("label", { className: "check" }, [h("input", { id: "lvAdvanced", type: "checkbox" }), t("I understand the risk and have a backup")])
723 ], () => {
724 if (!document.getElementById("lvAdvanced").checked) return formError(t("Confirm the advanced operation."));
725 const size = readUnit("lvReduce", 512) * 512;
726 if (!Number.isFinite(size) || size >= lv.size) return formError(t("The new size must be smaller than the current size."));
727 return { type: "lv-reduce", lv: `${lv.vg}/${lv.name}`, size, advanced: true };
728 });
731// ---------- queue ----------
732async function planQueue(operations) {
733 return withSudo((credentials) => api.disks.plan(state.sessionId, operations, { ...credentials }));
735async function queueOperation(op, confirmText) {
736 if (confirmText && !window.confirm(confirmText)) return;
737 const operations = [...state.queue, ...[].concat(op)];
738 state.busy = true; updateButtons(); setStatus(t("Validating the operation…"), "busy");
739 try {
740 const plan = await planQueue(operations);
741 if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {});
742 state.queue = operations; state.preview = plan;
743 if (el.formDialog.open) el.formDialog.close();
744 setStatus(t("Operation queued. Nothing has been changed on the server yet."));
745 render();
746 } catch (error) {
747 if (cancelled(error)) { setStatus(""); return; }
748 if (el.formDialog.open) formError(describe(error));
749 else setStatus(describe(error), "error");
750 } finally { state.busy = false; updateButtons(); }
752async function rebuildPreview() {
753 if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {});
754 state.preview = null;
755 if (state.queue.length) {
756 try { state.preview = await planQueue(state.queue); }
757 catch (error) { state.queue = []; setStatus(t("The queue no longer fits the current layout and was cleared: {error}", { error: describe(error) }), "error"); }
758 }
759 render();
762function describeOperation(op) {
763 const sizeText = (value, disk) => value === "max" ? t("maximum") : bytes(value * (diskByName(disk)?.logicalSectorSize || 512));
764 switch (op.type) {
765 case "create-table": return t("New {type} partition table on {disk}", { type: op.label === "dos" ? "MBR" : "GPT", disk: `/dev/${op.disk}` });
766 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 });
767 case "delete-partition": return t("Delete partition {number} of {disk}", { number: op.number, disk: `/dev/${op.disk}` });
768 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) });
769 case "format": return t("Format {device} as {fs}", { device: op.device, fs: op.fs });
770 case "set-label": return t("Label of {device}: {label}", { device: op.device, label: op.label || "—" });
771 case "set-name": return t("Name of partition {number} of {disk}: {name}", { number: op.number, disk: `/dev/${op.disk}`, name: op.name || "—" });
772 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") });
773 case "check": return t(op.repair ? "Check and repair {device}" : "Check {device} (read-only)", { device: op.device });
774 case "grow-fs": return t("Grow the file system on {device}", { device: op.device });
775 case "mount": return t("Mount {device} at {mountpoint}", { device: op.device, mountpoint: op.mountpoint });
776 case "unmount": return t("Unmount {device}", { device: op.device });
777 case "lv-extend": return t("Extend {lv} to {size}", { lv: op.lv, size: op.size === "max" ? t("maximum") : bytes(op.size) });
778 case "lv-reduce": return t("Reduce {lv} to {size}", { lv: op.lv, size: bytes(op.size) });
779 case "pv-resize": return t("Resize physical volume {device}", { device: op.device });
780 default: return op.type;
781 }
783const diskByName = (name) => state.inventory?.disks?.find((disk) => disk.name === name);
784function stepText(name, args) {
785 const disk = diskByPath(args[0]); const lbs = disk?.logicalSectorSize || 512;
786 const sectors = (value) => bytes(Number(value) * lbs);
787 switch (name) {
788 case "verify-disk": return t("Verify that {disk} still has the planned size and partition table", { disk: args[0] });
789 case "verify-size": return t("Verify the current size of {device}", { device: args[0] });
790 case "backup-table": return t("Save the partition table metadata of {disk} on the server (this is not a data backup)", { disk: args[0] });
791 case "require-free": return t("Check that {device} is not in use", { device: args[0] });
792 case "gpt-relocate": return t("Move the backup GPT header of {disk} to the end of the disk", { disk: args[0] });
793 case "create-table": return t("Write a new empty {type} partition table to {disk}", { type: args[1] === "dos" ? "MBR" : "GPT", disk: args[0] });
794 case "create-part": return t("Create partition {number} on {disk} ({size})", { number: args[1], disk: args[0], size: sectors(args[3]) });
795 case "delete-part": return t("Delete partition {number} of {disk} ({size})", { number: args[1], disk: args[0], size: sectors(args[3]) });
796 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]) });
797 case "set-type": return t("Set the type of partition {number} on {disk} to {type}", { number: args[1], disk: args[0], type: args[2] });
798 case "set-name": return t("Set the name of partition {number} on {disk}", { number: args[1], disk: args[0] });
799 case "set-attrs": return t("Set the GPT attributes of partition {number} on {disk}", { number: args[1], disk: args[0] });
800 case "set-boot": return t("Set the boot flag on {disk}", { disk: args[0] });
801 case "mkfs": return t("Create {fs} on {device}", { fs: args[1], device: args[0] });
802 case "set-label": return t("Set the label of {device}", { device: args[0] });
803 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] });
804 case "grow-fs": return t("Grow {fs} on {device} to the size of the device", { fs: args[1], device: args[0] });
805 case "shrink-fs": return t("Check and shrink {fs} on {device} to {size}", { fs: args[1], device: args[0], size: bytes(Number(args[2])) });
806 case "luks-resize": return t("Resize the LUKS mapping {name}", { name: args[0] });
807 case "pv-resize": return t("Resize the LVM physical volume {device}", { device: args[0] });
808 case "lv-extend": return t("Extend the logical volume {lv}", { lv: args[0] });
809 case "lv-reduce": return t("Reduce the logical volume {lv} to {size}", { lv: args[0], size: bytes(Number(args[1])) });
810 case "mount": return t("Mount {device} at {mountpoint}", { device: args[0], mountpoint: args[1] });
811 case "unmount": return t("Unmount {device}", { device: args[0] });
812 case "verify-part": return t("Verify the new geometry of partition {number} on {disk}", { number: args[1], disk: args[0] });
813 case "verify-fs": return t("Verify the file system on {device}", { device: args[0] });
814 default: return [name, ...args].join(" ");
815 }
818// ---------- apply ----------
819async function openApply(plan) {
820 const sessionId = state.sessionId;
821 const body = [];
822 body.push(h("h4", { text: t("Current layout") }), ...plan.before.map((view) => h("div", {}, [h("small", { text: view.path }), miniMap(view)])));
823 body.push(h("h4", { text: t("Resulting layout") }), ...plan.after.map((view) => h("div", {}, [h("small", { text: view.path }), miniMap(view, (segment) => segment.created)])));
824 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]))));
825 const warnings = [];
826 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."));
827 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(", ") }));
828 if (plan.offline.length) warnings.push(t("These file systems must stay unmounted during the job: {devices}.", { devices: plan.offline.join(", ") }));
829 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."));
830 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);
831 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."));
832 body.push(h("ul", { className: "warnings" }, warnings.map((text) => h("li", { text }))));
833 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")]));
834 el.applyBody.replaceChildren(...body);
835 el.applyError.hidden = true;
836 el.applyDialog.showModal();
837 const submit = async (event) => {
838 event.preventDefault();
839 if (plan.dataLoss && !document.getElementById("applyBackup").checked) { el.applyError.textContent = t("Confirm that a backup exists."); el.applyError.hidden = false; return; }
840 el.applySubmit.disabled = true;
841 try {
842 const result = await withSudo((credentials) => api.disks.apply(sessionId, plan.planId, { confirm: true, rememberSudo: true, ...credentials }));
843 el.applyDialog.close();
844 state.queue = []; state.preview = null;
845 startJobMonitor(result.jobId, plan.title);
846 render();
847 } catch (error) {
848 if (cancelled(error)) return;
849 el.applyError.textContent = describe(error); el.applyError.hidden = false;
850 if (["DISKS_PLAN_STALE", "DISKS_PLAN_EXPIRED"].includes(errorCode(error))) { el.applySubmit.disabled = true; await loadInventory({ privileged: true }); await rebuildPreview(); return; }
851 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(); }
852 } finally { el.applySubmit.disabled = false; }
853 };
854 el.applyForm.onsubmit = submit;
856async function applyQueue() {
857 if (!state.queue.length) return;
858 state.busy = true; updateButtons(); setStatus(t("Re-reading the current layout and validating the plan…"), "busy");
859 try { const plan = await planQueue(state.queue); if (state.preview?.planId) api.disks.cancelPlan(state.preview.planId).catch(() => {}); state.preview = plan; render(); await openApply(plan); setStatus(""); }
860 catch (error) { if (!cancelled(error)) setStatus(describe(error), "error"); }
861 finally { state.busy = false; updateButtons(); }
863async function autoExpand(suggestion) {
864 state.busy = true; updateButtons(); setStatus(t("Re-reading the layout and planning the expansion…"), "busy");
865 try {
866 const plan = await withSudo((credentials) => api.disks.autoExpand(state.sessionId, suggestion.id, credentials));
867 setStatus(""); await openApply(plan);
868 } catch (error) { if (!cancelled(error)) { setStatus(describe(error), "error"); if (errorCode(error) === "DISKS_SUGGESTION_GONE") loadInventory({ privileged: true }); } }
869 finally { state.busy = false; updateButtons(); }
872// ---------- jobs ----------
873function stopJobPolling() { clearTimeout(state.jobTimer); state.jobTimer = null; }
874function startJobMonitor(jobId, title) {
875 stopJobPolling();
876 state.job = { id: jobId, title: title || "", offset: 0, log: "", steps: [], plan: [], outcome: "running", state: "queued", error: null, disconnected: false };
877 el.jobPanel.hidden = false;
878 renderJob(); pollJob();
880async function pollJob() {
881 const job = state.job; if (!job || !state.sessionId) return;
882 stopJobPolling();
883 const token = state.generation;
884 try {
885 const status = await withSudo((credentials) => api.disks.jobStatus(state.sessionId, job.id, job.offset, credentials), t("Monitoring the job needs the sudo password again."));
886 if (token !== state.generation || state.job !== job) return;
887 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 });
888 job.log += status.log; job.offset = status.logOffset;
889 for (const marker of status.steps) job.steps[marker.index - 1] = marker.status;
890 renderJob();
891 if (job.outcome === "running") state.jobTimer = setTimeout(pollJob, 1500);
892 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 }); }
893 } catch (error) {
894 if (cancelled(error)) return;
895 if (errorCode(error) === "SESSION_NOT_CONNECTED" || errorCode(error) === "SESSION_NOT_FOUND") { job.disconnected = true; renderJob(); return; }
896 job.pollError = describe(error); renderJob();
897 state.jobTimer = setTimeout(pollJob, 5000);
898 }
900function renderJob() {
901 const job = state.job; if (!job) { el.jobPanel.hidden = true; return; }
902 el.jobPanel.hidden = false;
903 el.jobTitle.textContent = `${t("Disk job")} ${job.id.slice(0, 8)} — ${job.title}`;
904 const text = job.disconnected ? t("Connection lost. The job continues on the server; the status is read again after reconnecting.")
905 : { 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;
906 el.jobState.textContent = `${text}${job.pollError ? ` (${job.pollError})` : ""}`;
907 el.jobState.className = `job-state ${job.outcome}`;
908 // No percentage: the tools report no reliable progress, so the bar is indeterminate while running.
909 el.jobProgress.className = `progress ${job.outcome === "running" && !job.disconnected ? "indeterminate" : job.outcome}`;
910 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]); }));
911 el.jobLog.textContent = job.log.slice(-200000);
912 updateButtons(); renderSuggestions();
914async function checkJobs(silent = false) {
915 try {
916 const { jobs } = await withSudo((credentials) => api.disks.jobs(state.sessionId, credentials));
917 const running = jobs.find((job) => job.outcome === "running" || job.outcome === "unknown" && !silent);
918 if (running && !state.job) startJobMonitor(running.id, running.title);
919 if (!silent) showJobs(jobs);
920 } catch (error) { if (!silent && !cancelled(error)) setStatus(describe(error), "error"); }
922function showJobs(jobs) {
923 el.infoTitle.textContent = t("Disk jobs on this server");
924 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", {}, [
925 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 || "" }),
926 h("td", {}, h("button", { className: "mini", type: "button", text: t("Show"), onclick: () => { el.infoDialog.close(); startJobMonitor(job.id, job.title); } }))
927 ])))]) : 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).") }));
928 el.infoDialog.showModal();
931// ---------- SMART ----------
932async function showSmart() {
933 const disk = currentDisk(); if (!disk) return;
934 setStatus(t("Reading health data…"), "busy");
935 try {
936 const smart = await withSudo((credentials) => api.disks.smart(state.sessionId, disk.path, credentials));
937 setStatus("");
938 el.infoTitle.textContent = t("Health of {disk}", { disk: disk.path });
939 const rows = smart.available ? [
940 [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)],
941 [t("Temperature"), known(smart.temperatureC) ? `${smart.temperatureC} °C` : null], [t("Power-on hours"), smart.powerOnHours], [t("Power cycles"), smart.powerCycles],
942 [t("Reallocated sectors"), smart.reallocatedSectors], [t("Pending sectors"), smart.pendingSectors], [t("Offline uncorrectable"), smart.offlineUncorrectable], [t("CRC errors"), smart.crcErrors],
943 [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]
944 ] : [[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 })]];
945 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 })));
946 el.infoDialog.showModal();
947 } catch (error) { if (!cancelled(error)) setStatus(describe(error), "error"); }
950// ---------- events ----------
951el.formForm.addEventListener("submit", (event) => {
952 event.preventDefault();
953 const result = formHandler?.();
954 if (result && typeof result === "object") queueOperation(result);
955});
956for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("click", (event) => { if (event.target.closest("[data-close]")) dialog.close(); });
957document.addEventListener("click", (event) => { if (!el.contextMenu.hidden && !el.contextMenu.contains(event.target)) closeContextMenu(); });
958document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeContextMenu(); });
959el.closeButton.addEventListener("click", () => api.close());
960el.sessionSelect.addEventListener("change", () => {
961 const session = state.sessions.find((entry) => entry.sessionId === el.sessionSelect.value);
962 resetServer();
963 Object.assign(state, { sessionId: session?.sessionId || "", identity: session ? `${session.profileId}|${session.host}|${session.port}|${session.username}` : "", disconnected: false });
964 if (session) loadInventory(); else render();
965});
966el.refreshButton.addEventListener("click", () => loadInventory({ privileged: Boolean(state.inventory?.privileged) }));
967el.privilegedButton.addEventListener("click", () => loadInventory({ privileged: true }));
968el.jobsButton.addEventListener("click", () => checkJobs(false));
969el.smartButton.addEventListener("click", showSmart);
970el.tableButton.addEventListener("click", openTable);
971el.detailClose.addEventListener("click", () => { el.detailPanel.hidden = true; });
972el.queueApply.addEventListener("click", applyQueue);
973el.queueClear.addEventListener("click", () => { state.queue = []; rebuildPreview(); setStatus(t("Queue cleared; nothing was changed on the server.")); });
974el.queueUndo.addEventListener("click", () => { state.queue.pop(); rebuildPreview(); });
975el.jobClose.addEventListener("click", () => { if (state.job?.outcome !== "running") { stopJobPolling(); state.job = null; } el.jobPanel.hidden = true; });
977async function init() {
978 try { i18n.setLanguage(await api.ui.getLanguage()); } catch {}
979 i18n.apply(document);
980 api.ui.onLanguageChanged((language) => { i18n.setLanguage(language); i18n.apply(document); render(); renderJob(); });
981 try { const info = await api.getInfo(); el.pluginVersion.textContent = `${info.id} · ${info.version}`; } catch {}
982 await loadSessions();
983 setInterval(loadSessions, 5000);
985init();
SHA-25626642002354ce44ef2b787267266a57c77a68d58b0711da9a45a5e813d19f725

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6
Dateien