Fonte publicada
Transferir fonte ZIP
CH-J Tools
Navegue por diretórios e arquivos para uma versão específica do aplicativo.
CH-J / Server Manager
1704 arquivos
0.1.2-beta
Adicionado: · Construído:
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
let endedSessionId = "";9
let sessionsLoading = false;11
// Guard every continuation, including sudo retries and dialog answers, against a server switch.12
async function sessionResult(promise, token) {13
const value = await promise;14
if (token !== state.generation) throw new Error("CHJ_SESSION_CHANGED");15
return value;16
}17
const el = new Proxy({}, { get: (_target, id) => document.getElementById(id) });18
const MiB = 1048576;19
const state = {20
generation: 0, sessionId: "", identity: "", sessions: [], inventory: null, selectedDisk: null, selected: null,21
queue: [], preview: null, busy: false, job: null, jobTimer: null, sudo: null22
};24
// ---------- helpers ----------25
function 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;39
}40
const locale = () => (globalThis.__CHJ_I18N__?.manifest?.languages || []).find((entry) => entry.code === i18n.getLanguage())?.locale || ({ cs: "cs-CZ", de: "de-DE" }[i18n.getLanguage()] || "en-GB");41
const known = (value) => value !== null && value !== undefined && Number.isFinite(Number(value));42
function 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]}`;47
}48
const pct = (used, total) => known(used) && known(total) && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null;49
function setStatus(message, kind = "") { el.status.textContent = message || ""; el.status.className = `status ${kind}`; }50
function 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: )?/, "") };54
}55
const 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
};87
function 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;91
}92
const errorCode = (error) => parseError(error).code;94
// ---------- sudo ----------95
function 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
});106
}107
// Runs fn with no password first (root, NOPASSWD or a remembered password); asks only when sudo requires it.108
async 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; }124
}125
const cancelled = (error) => errorCode(error) === "CHJ_CANCELLED";127
// ---------- sessions ----------128
async 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");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
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();164
}166
// ---------- inventory ----------167
async 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(); }} }187
}189
function currentDisk() { return state.inventory?.disks?.find((disk) => disk.name === state.selectedDisk) || null; }190
function nodeByPath(path) { return state.inventory?.nodes?.find((node) => node.path === path || node.displayPath === path || `/dev/${node.kname}` === path) || null; }191
function 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).194
function 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 };202
}204
// ---------- rendering ----------205
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" };206
const modelText = (disk) => (disk.model && disk.vendor && disk.model.startsWith(disk.vendor) ? disk.model : [disk.vendor, disk.model].filter(Boolean).join(" ")) || "—";207
const LEGEND = [["ext4", "EXT4"], ["xfs", "XFS"], ["btrfs", "Btrfs"], ["fat", "FAT32"], ["ntfs", "NTFS"], ["swap", "Linux swap"], ["lvm", "LVM"], ["luks", "LUKS"], ["free", "Unallocated"], ["unknown", "Unknown"]];208
function fsClass(fstype, kind) { return kind === "free" ? "free" : FS_CLASS[fstype] || (fstype ? "unknown" : "none"); }209
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"); }210
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; }212
function 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();224
}226
function 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.") }));237
}239
function fact(label, value) { return h("div", {}, [h("dt", { text: label }), h("dd", { text: value ?? "—" })]); }240
function 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);265
}267
function 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);270
}271
function 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));274
}276
function 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])));307
}309
// Drag the right boundary of the selected partition; the exact value is confirmed in the resize dialog.310
function 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);331
}333
function 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;340
}341
function 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");345
}347
function 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);384
}386
function 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);408
}410
function 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
}));423
}425
function 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) })));429
}431
function 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");440
}441
const manageable = () => Boolean(state.inventory?.supported && state.inventory.privilege?.mode !== "none");443
// ---------- details ----------444
function 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
]);463
}464
function 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
]);475
}476
function 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;480
}482
// ---------- capability checks (mirror Core rules; Core validates again) ----------483
const SHRINKABLE = new Set(["ext2", "ext3", "ext4", "btrfs", "ntfs", "swap"]);484
const GROWABLE = new Set(["ext2", "ext3", "ext4", "xfs", "btrfs", "ntfs", "swap", "crypto_LUKS", "LVM2_member"]);485
const tool = (name) => Boolean(state.inventory?.tools?.[name]);486
function 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.") };513
}515
function 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;546
}547
function 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();554
}555
function closeContextMenu() { el.contextMenu.hidden = true; }557
// ---------- forms ----------558
function 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
]);566
}567
function 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;571
}572
function field(label, control, hint) { return h("label", {}, [h("span", { text: label }), control, hint && h("small", { className: "hint", text: hint })]); }573
function 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;578
}579
let formHandler = null;580
function 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();588
}589
function formError(message) { el.formError.textContent = message; el.formError.hidden = false; }591
function 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/", "") })])])));598
}600
function 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);632
}634
function 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
});652
}653
function 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 }));659
}660
function 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" }));666
}667
function 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 }));671
}672
function 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 }));675
}676
function 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
});697
}698
function 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() }));701
}702
function 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 }));709
}710
function 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
});722
}723
function 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
});735
}737
// ---------- queue ----------738
async function planQueue(operations) {739
const requestGeneration = state.generation;740
return withSudo((credentials) => api.disks.plan(state.sessionId, operations, { ...credentials }));741
}742
async 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();} }759
}760
async 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();769
}771
function 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
}791
}792
const diskByName = (name) => state.inventory?.disks?.find((disk) => disk.name === name);793
function 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
}825
}827
// ---------- apply ----------828
async 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;870
}871
async 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();} }878
}879
async 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();} }887
}889
// ---------- jobs ----------890
function stopJobPolling() { clearTimeout(state.jobTimer); state.jobTimer = null; }891
function 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();896
}897
async 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
}917
}918
function 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();931
}932
async 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"); }940
}941
function 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();948
}950
// ---------- SMART ----------951
async 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"); }968
}970
// ---------- events ----------971
el.formForm.addEventListener("submit", (event) => {972
event.preventDefault();973
const result = formHandler?.();974
if (result && typeof result === "object") queueOperation(result);975
});976
for (const dialog of document.querySelectorAll("dialog")) dialog.addEventListener("click", (event) => { if (event.target.closest("[data-close]")) dialog.close(); });977
document.addEventListener("click", (event) => { if (!el.contextMenu.hidden && !el.contextMenu.contains(event.target)) closeContextMenu(); });978
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeContextMenu(); });979
el.closeButton.addEventListener("click", () => api.close());980
el.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
});987
el.refreshButton.addEventListener("click", () => loadInventory({ privileged: Boolean(state.inventory?.privileged) }));988
el.privilegedButton.addEventListener("click", () => loadInventory({ privileged: true }));989
el.jobsButton.addEventListener("click", () => checkJobs(false));990
el.smartButton.addEventListener("click", showSmart);991
el.tableButton.addEventListener("click", openTable);992
el.detailClose.addEventListener("click", () => { el.detailPanel.hidden = true; });993
el.queueApply.addEventListener("click", applyQueue);994
el.queueClear.addEventListener("click", () => { state.queue = []; rebuildPreview(); setStatus(t("Queue cleared; nothing was changed on the server.")); });995
el.queueUndo.addEventListener("click", () => { state.queue.pop(); rebuildPreview(); });996
el.jobClose.addEventListener("click", () => { if (state.job?.outcome !== "running") { stopJobPolling(); state.job = null; } el.jobPanel.hidden = true; });998
async 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();1004
}1005
init();1007
setInterval(() => { if (!document.hidden) void loadSessions().catch(() => {}); }, 3000);SHA-256
6e3c4bde96f4166d8fecffb6e125a4473e9b6b5720ec5ba6bb82e609111e77f5CH-J Proprietary Software License 1.14 · A fonte é fornecida sob o CH-J Proprietary Software License 1.14. A sua disponibilidade não altera os termos licença nem concede direitos adicionais.
Ficheiro SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f