CH-J ToolsSSH によるサーバー管理
メニュー
公開済みのソース

CH-J Tools

特定のアプリケーションリリースのディレクトリとファイルを閲覧できます。

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

CH-J Proprietary Software License 1.14 · ソースは CH-J Proprietary Software License 1.14 に基づいて提供されています。公開されていても、ライセンス条項が変わったり、追加の権利が付与されたりすることはありません。

アーカイブの SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
ファイル