Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
406 souborů
core-20261024T135205Z-7c487d719de2
Přidáno: · Sestaveno:
1
"use strict";3
// chj.disks UI. All server data is rendered as text (never as HTML). Every change is queued, validated and4
// simulated by Core (plan), reviewed by the user and only then started as a remote job (apply).5
const api = window.chjPlugin;6
const i18n = window.chjDisksI18n.createI18n("en");7
const t = (key, values) => i18n.t(key, values);8
const el = new Proxy({}, { get: (_target, id) => document.getElementById(id) });9
const MiB = 1048576;10
const state = {11
generation: 0, sessionId: "", identity: "", sessions: [], inventory: null, selectedDisk: null, selected: null,12
queue: [], preview: null, busy: false, job: null, jobTimer: null, sudo: null13
};15
// ---------- helpers ----------16
function 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;30
}31
const locale = () => ({ cs: "cs-CZ", de: "de-DE" }[i18n.getLanguage()] || "en-GB");32
const known = (value) => value !== null && value !== undefined && Number.isFinite(Number(value));33
function 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]}`;38
}39
const pct = (used, total) => known(used) && known(total) && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null;40
function setStatus(message, kind = "") { el.status.textContent = message || ""; el.status.className = `status ${kind}`; }41
function 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: )?/, "") };45
}46
const 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
};78
function 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;82
}83
const errorCode = (error) => parseError(error).code;85
// ---------- sudo ----------86
function 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
});97
}98
// Runs fn with no password first (root, NOPASSWD or a remembered password); asks only when sudo requires it.99
async 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
}112
}113
const cancelled = (error) => errorCode(error) === "CHJ_CANCELLED";115
// ---------- sessions ----------116
async 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
}150
}151
// Data of one server is never shown as the state of another one.152
function 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();159
}161
// ---------- inventory ----------162
async 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(); } }181
}183
function currentDisk() { return state.inventory?.disks?.find((disk) => disk.name === state.selectedDisk) || null; }184
function nodeByPath(path) { return state.inventory?.nodes?.find((node) => node.path === path || node.displayPath === path || `/dev/${node.kname}` === path) || null; }185
function 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).188
function 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 };196
}198
// ---------- rendering ----------199
const FS_CLASS = { ext2: "ext", ext3: "ext", ext4: "ext4", xfs: "xfs", btrfs: "btrfs", vfat: "fat", ntfs: "ntfs", swap: "swap", LVM2_member: "lvm", crypto_LUKS: "luks", free: "free", zfs_member: "zfs", linux_raid_member: "raid" };200
const modelText = (disk) => (disk.model && disk.vendor && disk.model.startsWith(disk.vendor) ? disk.model : [disk.vendor, disk.model].filter(Boolean).join(" ")) || "—";201
const LEGEND = [["ext4", "EXT4"], ["xfs", "XFS"], ["btrfs", "Btrfs"], ["fat", "FAT32"], ["ntfs", "NTFS"], ["swap", "Linux swap"], ["lvm", "LVM"], ["luks", "LUKS"], ["free", "Unallocated"], ["unknown", "Unknown"]];202
function fsClass(fstype, kind) { return kind === "free" ? "free" : FS_CLASS[fstype] || (fstype ? "unknown" : "none"); }203
function fsName(fstype) { return { crypto_LUKS: "LUKS", LVM2_member: "LVM PV", linux_raid_member: "RAID", zfs_member: "ZFS", vfat: "FAT", swap: "swap" }[fstype] || fstype || t("unformatted"); }204
function mediaText(disk) { return { hdd: "HDD", ssd: "SSD", nvme: "NVMe", virtual: t("virtual disk"), loop: t("loop device (image file)"), unknown: t("unknown") }[disk.mediaType] || disk.mediaType; }206
function 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();218
}220
function 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.") }));231
}233
function fact(label, value) { return h("div", {}, [h("dt", { text: label }), h("dd", { text: value ?? "—" })]); }234
function 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);259
}261
function 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);264
}265
function 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));268
}270
function 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])));301
}303
// Drag the right boundary of the selected partition; the exact value is confirmed in the resize dialog.304
function 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);325
}327
function 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;334
}335
function 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");339
}341
function 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);378
}380
function 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);402
}404
function 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
}));417
}419
function 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) })));423
}425
function 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");434
}435
const manageable = () => Boolean(state.inventory?.supported && state.inventory.privilege?.mode !== "none");437
// ---------- details ----------438
function 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
]);457
}458
function 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
]);469
}470
function 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;474
}476
// ---------- capability checks (mirror Core rules; Core validates again) ----------477
const SHRINKABLE = new Set(["ext2", "ext3", "ext4", "btrfs", "ntfs", "swap"]);478
const GROWABLE = new Set(["ext2", "ext3", "ext4", "xfs", "btrfs", "ntfs", "swap", "crypto_LUKS", "LVM2_member"]);479
const tool = (name) => Boolean(state.inventory?.tools?.[name]);480
function 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.") };507
}509
function 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;540
}541
function 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();548
}549
function closeContextMenu() { el.contextMenu.hidden = true; }551
// ---------- forms ----------552
function 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
]);560
}561
function 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;565
}566
function field(label, control, hint) { return h("label", {}, [h("span", { text: label }), control, hint && h("small", { className: "hint", text: hint })]); }567
function 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;572
}573
let formHandler = null;574
function 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();582
}583
function formError(message) { el.formError.textContent = message; el.formError.hidden = false; }585
function 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/", "") })])])));592
}594
function 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);626
}628
function 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
});646
}647
function 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 }));653
}654
function 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" }));660
}661
function 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 }));665
}666
function 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 }));669
}670
function 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
});691
}692
function 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() }));695
}696
function 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 }));703
}704
function 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
});716
}717
function 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
});729
}731
// ---------- queue ----------732
async function planQueue(operations) {733
return withSudo((credentials) => api.disks.plan(state.sessionId, operations, { ...credentials }));734
}735
async 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(); }751
}752
async 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();760
}762
function 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
}782
}783
const diskByName = (name) => state.inventory?.disks?.find((disk) => disk.name === name);784
function 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
}816
}818
// ---------- apply ----------819
async 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;855
}856
async 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(); }862
}863
async 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(); }870
}872
// ---------- jobs ----------873
function stopJobPolling() { clearTimeout(state.jobTimer); state.jobTimer = null; }874
function 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();879
}880
async 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
}899
}900
function 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();913
}914
async 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"); }921
}922
function 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();929
}931
// ---------- SMART ----------932
async 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"); }948
}950
// ---------- events ----------951
el.formForm.addEventListener("submit", (event) => {952
event.preventDefault();953
const result = formHandler?.();954
if (result && typeof result === "object") queueOperation(result);955
});956
for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("click", (event) => { if (event.target.closest("[data-close]")) dialog.close(); });957
document.addEventListener("click", (event) => { if (!el.contextMenu.hidden && !el.contextMenu.contains(event.target)) closeContextMenu(); });958
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeContextMenu(); });959
el.closeButton.addEventListener("click", () => api.close());960
el.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
});966
el.refreshButton.addEventListener("click", () => loadInventory({ privileged: Boolean(state.inventory?.privileged) }));967
el.privilegedButton.addEventListener("click", () => loadInventory({ privileged: true }));968
el.jobsButton.addEventListener("click", () => checkJobs(false));969
el.smartButton.addEventListener("click", showSmart);970
el.tableButton.addEventListener("click", openTable);971
el.detailClose.addEventListener("click", () => { el.detailPanel.hidden = true; });972
el.queueApply.addEventListener("click", applyQueue);973
el.queueClear.addEventListener("click", () => { state.queue = []; rebuildPreview(); setStatus(t("Queue cleared; nothing was changed on the server.")); });974
el.queueUndo.addEventListener("click", () => { state.queue.pop(); rebuildPreview(); });975
el.jobClose.addEventListener("click", () => { if (state.job?.outcome !== "running") { stopJobPolling(); state.job = null; } el.jobPanel.hidden = true; });977
async 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);984
}985
init();SHA-256
26642002354ce44ef2b787267266a57c77a68d58b0711da9a45a5e813d19f725CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
476ed8aef0f1e96a5acdf223127b6692acdf4492eeaf5d0816b6e2a634fbbda6