'use strict'; // Copyright (C) 2026 permails // Licensed under the GNU General Public License v3.0 // // Modern Storage Manager (4-tab single page): 物理磁盘 / 分区管理 / RAID / Btrfs. // Ported to the LuCI AMD view architecture from the DESIGN_SPEC demo. Every // action is wired to the real `luci.diskbox` rpcd backend; no mock data. All // protection rules are preserved: is_boot partitions are locked, / , /rom and // /overlay* are brick-level (never unmountable), mount targets require // >= 64 MiB, and the backend's destructive `mk_partition_table` (parted // mklabel) is only offered on disks that have no partitions yet. 'require view'; 'require rpc'; 'require ui'; 'require dom'; var callListDevices = rpc.declare({ object: 'luci.diskbox', method: 'list_devices', expect: { devices: {} } }); var callGetDiskInfo = rpc.declare({ object: 'luci.diskbox', method: 'get_disk_info', params: ['device'] }); var callGetSmartAttr = rpc.declare({ object: 'luci.diskbox', method: 'get_smart_attr', params: ['device'] }); var callGetFormatCmd = rpc.declare({ object: 'luci.diskbox', method: 'get_format_cmd', expect: { formats: {} } }); var callFormatPartition = rpc.declare({ object: 'luci.diskbox', method: 'format_partition', params: ['partition', 'filesystem', 'force'] }); var callMkPartitionTable = rpc.declare({ object: 'luci.diskbox', method: 'mk_partition_table', params: ['device', 'table'] }); var callCreatePartition = rpc.declare({ object: 'luci.diskbox', method: 'create_partition', params: ['device', 'start_sec', 'end_sec', 'type'] }); var callRemovePartition = rpc.declare({ object: 'luci.diskbox', method: 'remove_partition', params: ['device', 'number'] }); var callEjectDevice = rpc.declare({ object: 'luci.diskbox', method: 'eject_device', params: ['device'] }); var callRescanDisks = rpc.declare({ object: 'luci.diskbox', method: 'rescan_disks' }); var callGetMountPoints = rpc.declare({ object: 'luci.diskbox', method: 'get_mount_points', expect: { mount_points: [] } }); var callMount = rpc.declare({ object: 'luci.diskbox', method: 'mount', params: ['device', 'point', 'fs', 'options'] }); var callUmount = rpc.declare({ object: 'luci.diskbox', method: 'umount', params: ['point'] }); var callListRaidDevices = rpc.declare({ object: 'luci.diskbox', method: 'list_raid_devices', expect: { raid_devices: {} } }); var callCreateRaid = rpc.declare({ object: 'luci.diskbox', method: 'create_raid', params: ['name', 'level', 'members'] }); var callListBtrfsDevices = rpc.declare({ object: 'luci.diskbox', method: 'list_btrfs_devices', expect: { btrfs_devices: {} } }); var callGetBtrfsInfo = rpc.declare({ object: 'luci.diskbox', method: 'get_btrfs_info', params: ['uuid'] }); var callGetBtrfsSubvolumes = rpc.declare({ object: 'luci.diskbox', method: 'get_btrfs_subvolumes', params: ['uuid'], expect: { subvolumes: [] } }); var callCreateBtrfs = rpc.declare({ object: 'luci.diskbox', method: 'create_btrfs', params: ['label', 'level', 'members'] }); var callBtrfsSetLabel = rpc.declare({ object: 'luci.diskbox', method: 'btrfs_set_label', params: ['uuid', 'label'] }); var callBtrfsSubvolCreate = rpc.declare({ object: 'luci.diskbox', method: 'btrfs_subvol_create', params: ['uuid', 'path'] }); var callBtrfsSubvolDelete = rpc.declare({ object: 'luci.diskbox', method: 'btrfs_subvol_delete', params: ['uuid', 'path'] }); var callBtrfsSubvolSetDefault = rpc.declare({ object: 'luci.diskbox', method: 'btrfs_subvol_set_default', params: ['uuid', 'path'] }); var callBtrfsSnapshotCreate = rpc.declare({ object: 'luci.diskbox', method: 'btrfs_snapshot_create', params: ['uuid', 'source', 'dest', 'readonly'] }); // Minimum size for a meaningful mount target (bytes). System/boot partitions // (is_boot) and tiny firmware slivers (< 64 MiB) are excluded from the mount // picker so they can never be chosen as a mount point. var MIN_MOUNT_BYTES = 64 * 1024 * 1024; // Cache-busting token for the component stylesheet (bump on each CSS change). var DISKBOX_CSS_V = '20260919_a'; // Active top tab (module-level so it survives re-renders within a page load). // Persisted in the URL hash (#partitions) so a location.reload() fired after a // mount / delete / eject / rescan returns the user to the SAME tab instead of // bouncing back to the default Physical Disks tab and hiding the result. var MAIN_TAB_KEYS = ['disks', 'partitions', 'raid', 'btrfs']; var activeMainTab = (function() { try { var h = (typeof location !== 'undefined' && location.hash) ? location.hash.replace(/^#/, '') : ''; if (MAIN_TAB_KEYS.indexOf(h) !== -1) return h; } catch (e) { /* ignore */ } return 'disks'; })(); // Reflect the active tab in the URL hash. replaceState (not location.hash=) // keeps the back button clean - no new history entry per tab click. function syncTabHash() { try { if (typeof history !== 'undefined' && history.replaceState) history.replaceState(null, '', '#' + activeMainTab); } catch (e) { /* some embedded contexts block replaceState */ } } // Set by render(); lets buttons deep in a tab switch to another tab + redraw. var currentDraw = null; // Per-disk get_disk_info cache (this page's JS context). Re-selecting the // Partition Management tab then renders instantly instead of re-running the // slow smartctl-based backend query. Every mutating action ends in // location.reload(), which rebuilds the context and resets this cache. var _diskInfoCache = {}; // Inject the component stylesheet once. There is no LuCI template for this // view, so we add a to document.head from the render step. function ensureStyles() { if (typeof document === 'undefined' || document.getElementById('diskbox-static-css')) return; var link = document.createElement('link'); link.id = 'diskbox-static-css'; link.rel = 'stylesheet'; link.href = '/luci-static/resources/view/diskbox/diskbox.css?v=' + DISKBOX_CSS_V; (document.head || document.getElementsByTagName('head')[0]).appendChild(link); } // Switch to another top tab, redraw, and (optionally) scroll to a per-disk // section by id `disk-group-`. function gotoTab(key, devName) { activeMainTab = key; syncTabHash(); if (typeof currentDraw === 'function') currentDraw(); if (devName) { setTimeout(function() { var el = document.getElementById('disk-group-' + devName); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 80); } } function translatePowerStatus(status) { if (!status || status === '-') return '-'; var map = { 'ACTIVE': _('Active'), 'STANDBY': _('Standby'), 'SLEEP': _('Sleep') }; return map[status] || status; } function translateHealth(health) { if (!health || health === '-') return '-'; var map = { 'Normal': _('Normal'), 'PASSED': _('Passed'), 'Warning': _('Warning'), 'Urgent': _('Urgent'), 'FAILED': _('Failed') }; return map[health] || health; } function translateFs(fs, type) { if (type === 'extended' || fs === 'extended') return _('Extended Partition'); if (fs === 'Free Space' || type === 'free') return _('Free Space'); if (fs === 'raw') return _('Unformatted'); if (!fs || fs === '-') return '-'; return fs.toUpperCase(); } // Human-readable size from a byte count (1024-based), e.g. 113.17 GB. function formatBytes(bytes) { if (!bytes || bytes <= 0) return '0 B'; var units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; var b = bytes, i = 0; while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; } return (i === 0 ? b.toFixed(0) : b.toFixed(2)) + ' ' + units[i]; } // Five-state semantic classification purely from backend facts (no size // guessing): is_boot -> protected; mounted -> in use; unmounted + fstype -> // data present; unmounted + no fs -> empty/safe. Unallocated is computed // separately by the bar/chip builders. function partState(p) { var fstype = (p.fstype || p.fs) || ''; var mounted = p.mount_point && p.mount_point !== '-'; if (p.is_boot) return { cls: 'part-state-boot', tag: _('Boot / Firmware (protected)') }; if (mounted) return { cls: 'part-state-inuse', tag: _('Data (in use)') }; var hasFs = fstype && fstype !== 'raw' && fstype !== '-' && fstype !== 'unknown' && fstype !== 'Free Space' && fstype.toLowerCase() !== 'free space'; if (hasFs) return { cls: 'part-state-unmounted', tag: _('Data (unmounted)') }; return { cls: 'part-state-raw', tag: _('Empty (no filesystem)') }; } // A label+checkbox "member" chip for the RAID / Btrfs creation forms. function memberChip(value, label) { var cb = E('input', { 'type': 'checkbox', 'value': value }); return E('label', { 'class': 'checkbox-chip' }, [ cb, E('span', {}, label) ]); } // Whether a partition / whole-disk entry is already mounted. Mounted devices // must never be RAID/Btrfs members - creating the array/filesystem would // ERASE them. function isMountedPart(p) { return p.inuse === true || (p.mount_point && p.mount_point !== '-'); } // Build the group of selectable member chips from the device list. Eligible // members = free, unmounted, non-boot partitions (or a whole disk with no // partitions). System disks, is_boot partitions and anything already mounted // are excluded - they are never safe RAID/Btrfs members. When no member // qualifies, emptyMsg (if given) is shown in place of the chips so the field // reads as "nothing to pick" rather than a missing control. function buildMemberChips(devices, emptyMsg) { var group = E('div', { 'class': 'checkbox-chip-group' }); Object.keys(devices).forEach(function(k) { var dev = devices[k]; if (!dev) return; var parts = dev.partitions || []; if (parts.length === 0) { if (!dev.is_system && !isMountedPart(dev)) { group.appendChild(memberChip(dev.path, dev.path + ' ' + (dev.size_formated || ''))); } } else { parts.forEach(function(p) { // eMMC hardware boot partitions (mmcblkXboot0/1) hold firmware / // bootloader data. Even when the backend's is_boot flag misses // them, never offer them as RAID/Btrfs members. var isBootDev = (p.name || '').indexOf('boot') !== -1; if (!p.is_boot && !isMountedPart(p) && !isBootDev) { group.appendChild(memberChip(p.path, p.path + ' ' + (p.size_formated || ''))); } }); } }); if (!group.firstChild && emptyMsg) { group.appendChild(E('div', { 'class': 'chip-empty-box' }, emptyMsg)); } return group; } // Find a mount point for a given /dev/... device in the mount-point list. function mountPointFor(mountPoints, devPath) { if (!mountPoints || !devPath) return null; for (var i = 0; i < mountPoints.length; i++) { if (mountPoints[i] && mountPoints[i].device === devPath) return mountPoints[i].mount_point; } return null; } // Find the mount point of any member device of a Btrfs pool (a pool is mounted // through one of its member devices). function btrfsMountPoint(mountPoints, membersStr) { if (!membersStr) return null; var members = membersStr.split(',').map(function(s) { return s.trim(); }).filter(Boolean); for (var i = 0; i < members.length; i++) { var mp = mountPointFor(mountPoints, members[i]); if (mp) return mp; } return null; } return view.extend({ load: function() { return Promise.all([ callListDevices(), callGetMountPoints(), callListRaidDevices(), callListBtrfsDevices(), callGetFormatCmd() ]); }, // On-theme confirmation dialog (replaces native confirm()). Cancel is // neutral, the destructive confirm is negative - consistent with the // rest of the app's modals. onDismiss runs on Cancel (if given). showConfirm: function(title, message, onConfirm, onDismiss) { ui.showModal(title, [ E('p', { 'style': 'white-space:pre-line; margin:0 0 1.25rem 0;' }, message), E('div', { 'class': 'right', 'style': 'margin-top:1rem; display:flex; justify-content:flex-end; gap:8px;' }, [ E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function(ev) { ev.preventDefault(); ui.hideModal(); if (typeof onDismiss === 'function') onDismiss(); } }, _('Cancel')), E('button', { 'class': 'btn cbi-button cbi-button-negative', 'click': function(ev) { ev.preventDefault(); ui.hideModal(); if (typeof onConfirm === 'function') onConfirm(); } }, _('Confirm')) ]) ]); }, showRaidInfoModal: function(r) { var members = (r.members_str || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean); var rows = [ [ _('RAID Level'), r.level || '-' ], [ _('Status'), ((r.active || '') + (r.status ? ' / ' + r.status : '')) || '-' ], [ _('Size'), r.size_formated || '-' ], [ _('Members'), members.join(', ') || '-' ] ]; var t = E('table', { 'class': 'table cbi-section-table' }, [ rows.map(function(row) { return E('tr', { 'class': 'tr' }, [ E('td', { 'class': 'td', 'style': 'font-weight:600; width:40%;' }, row[0]), E('td', { 'class': 'td' }, row[1]) ]); }) ]); ui.showModal(_('RAID Details') + ' - ' + (r.path || r.name), [ t ]); }, showSmartModal: function(dev) { ui.showModal(_('S.M.A.R.T Attributes') + ' - /dev/' + dev, [ E('p', { 'class': 'spinning' }, _('Collecting data...')) ]); callGetSmartAttr(dev).then(function(res) { var attrs = (res && res.attributes) ? res.attributes : (Array.isArray(res) ? res : []); if (attrs.length === 0) { ui.showModal(_('S.M.A.R.T Attributes') + ' - /dev/' + dev, [ E('p', { 'style': 'font-style:italic; padding:1rem;' }, _('No SMART attributes to display.')) ]); return; } var modalContent; if (attrs[0].key !== undefined) { var table = E('table', { 'class': 'table cbi-section-table' }, [ E('tr', { 'class': 'tr table-titles' }, [ E('th', { 'class': 'th' }, _('Key')), E('th', { 'class': 'th' }, _('Value')) ]) ]); attrs.forEach(function(item) { table.appendChild(E('tr', { 'class': 'tr' }, [ E('td', { 'class': 'td' }, item.key), E('td', { 'class': 'td' }, item.value) ])); }); modalContent = table; } else { var table = E('table', { 'class': 'table cbi-section-table' }, [ E('tr', { 'class': 'tr table-titles' }, [ E('th', { 'class': 'th' }, _('ID')), E('th', { 'class': 'th' }, _('Attribute')), E('th', { 'class': 'th' }, _('Flag')), E('th', { 'class': 'th' }, _('Value')), E('th', { 'class': 'th' }, _('Worst')), E('th', { 'class': 'th' }, _('Thresh')), E('th', { 'class': 'th' }, _('Type')), E('th', { 'class': 'th' }, _('Updated')), E('th', { 'class': 'th' }, _('Raw')) ]) ]); attrs.forEach(function(item) { var isCritical = ((item.id === '05' || item.id === 'C5') && item.raw !== '0'); var rowStyle = isCritical ? 'background-color:rgba(var(--error-color-high-rgb), 0.25) !important;' : ''; table.appendChild(E('tr', { 'class': 'tr', 'style': rowStyle }, [ E('td', { 'class': 'td' }, E('code', {}, item.id)), E('td', { 'class': 'td' }, item.attrbute || item.attribute), E('td', { 'class': 'td' }, item.flag || '-'), E('td', { 'class': 'td' }, item.value || '-'), E('td', { 'class': 'td' }, item.worst || '-'), E('td', { 'class': 'td' }, item.thresh || '-'), E('td', { 'class': 'td' }, item.type || '-'), E('td', { 'class': 'td' }, item.updated || '-'), E('td', { 'class': 'td' }, E('strong', {}, item.raw || '-')) ])); }); modalContent = table; } ui.showModal(_('S.M.A.R.T Attributes') + ' - /dev/' + dev, [ E('div', { 'style': 'max-height:70vh; overflow-y:auto;' }, [ modalContent ]), E('div', { 'class': 'right', 'style': 'margin-top:1rem;' }, [ E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': ui.hideModal }, _('Close')) ]) ]); }); }, showFormatModal: function(partName, formatCmds, currentFs, onSuccess) { var selFs = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }); Object.keys(formatCmds).forEach(function(fs) { var opt = E('option', { 'value': fs }, fs.toUpperCase()); if (fs === currentFs) opt.selected = true; selFs.appendChild(opt); }); var statusP = E('p', { 'style': 'color:var(--error-color-high); font-weight:bold; margin-top:8px;' }); ui.showModal(_('Format partition: /dev/%s').format(partName), [ E('p', {}, _('Formatting partition will ERASE all data stored on it.')), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('File System')), E('div', { 'class': 'cbi-value-field' }, selFs) ]), statusP, E('div', { 'class': 'right', 'style': 'margin-top:1.5rem; display:flex; justify-content:flex-end; gap:8px;' }, [ E('button', { 'class': 'btn cbi-button cbi-button-reset', 'click': ui.hideModal }, _('Cancel')), E('button', { 'class': 'btn cbi-button cbi-button-apply', 'click': function(ev) { ev.preventDefault(); var targetFs = selFs.value; this.disabled = true; callFormatPartition(partName, targetFs).then(function(res) { if (res && res.code === 3) { // Soft block: partition holds data - ask for confirmation then retry with force this.disabled = false; dom.content(statusP, [ E('p', { 'style': 'color:var(--warn-color-high); font-weight:bold;' }, (res.error || _('This partition contains data.')) + ' ') ]); var confirmBtn = E('button', { 'class': 'btn cbi-button cbi-button-apply', 'click': function() { this.disabled = true; dom.content(statusP, [ E('span', { 'class': 'spinning' }, _('Formatting...')) ]); callFormatPartition(partName, targetFs, 1).then(function(r2) { if (r2 && r2.code === 0) { ui.addNotification(null, E('p', {}, _('Partition formatted successfully!'))); ui.hideModal(); if (typeof onSuccess === 'function') { onSuccess(); } else { location.reload(); } } else { this.disabled = false; dom.content(statusP, r2 ? (r2.error || _('Format failed.')) : _('Format failed.')); } }.bind(this)); } }, _('Force Format')); var cancelBtn = E('button', { 'class': 'btn cbi-button cbi-button-reset', 'click': ui.hideModal }, _('Cancel')); statusP.appendChild(E('div', { 'style': 'display:flex; gap:8px; margin-top:8px;' }, [confirmBtn, cancelBtn])); } else if (res && res.code === 0) { ui.addNotification(null, E('p', {}, _('Partition formatted successfully!'))); ui.hideModal(); if (typeof onSuccess === 'function') onSuccess(); else location.reload(); } else { this.disabled = false; dom.content(statusP, res ? (res.error || _('Format failed.')) : _('Format failed.')); } }.bind(this)); } }, _('Format')) ]) ]); }, // Mount / re-mount dialog. Candidates are non-boot partitions >= 64 MiB (or // a whole non-system disk if it has no partitions). Prefills the selected // partition's current point + options when editing an existing mount. showMountModal: function(devName, dev, info, selectedPart, data) { var mountPoints = data[1] || []; var parts = (info && info.partitions) || (dev && dev.partitions) || []; var candidates = []; parts.forEach(function(p) { if (p.is_boot) return; if ((p.size || 0) < MIN_MOUNT_BYTES) return; if (p.number && p.number > 0) candidates.push({ path: p.path, label: p.path + ' ' + (p.size_formated || '') }); }); if (candidates.length === 0 && dev && !dev.is_system) { candidates.push({ path: dev.path, label: dev.path + ' ' + (dev.size_formated || '') }); } var curPoint = (selectedPart && selectedPart.mount_point && selectedPart.mount_point !== '-') ? selectedPart.mount_point : '/mnt/'; if (selectedPart && curPoint === '/mnt/' && selectedPart.name) curPoint = '/mnt/' + selectedPart.name; var curOpts = 'rw,noatime'; mountPoints.forEach(function(mp) { if (selectedPart && mp.device === selectedPart.path && mp.mount_options) curOpts = mp.mount_options; }); var devSel = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }); if (candidates.length === 0) { devSel.appendChild(E('option', { 'value': '' }, _('-- No mountable partitions --'))); } candidates.forEach(function(c) { var o = E('option', { 'value': c.path }, c.label); if (selectedPart && c.path === selectedPart.path) o.selected = true; devSel.appendChild(o); }); var fsSel = E('select', { 'class': 'cbi-input-select', 'style': 'width:100%;' }, [ E('option', { 'value': 'auto', 'selected': 'selected' }, 'auto'), E('option', { 'value': 'ext4' }, 'ext4'), E('option', { 'value': 'ext3' }, 'ext3'), E('option', { 'value': 'ext2' }, 'ext2'), E('option', { 'value': 'vfat' }, 'vfat'), E('option', { 'value': 'exfat' }, 'exfat'), E('option', { 'value': 'ntfs' }, 'ntfs'), E('option', { 'value': 'btrfs' }, 'btrfs') ]); var pointIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': curPoint }); var optsIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': curOpts }); ui.showModal(_('Mount a device'), [ E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Device')), E('div', { 'class': 'cbi-value-field' }, devSel) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('File System')), E('div', { 'class': 'cbi-value-field' }, fsSel) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Mount Point')), E('div', { 'class': 'cbi-value-field' }, pointIn) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Mount Options')), E('div', { 'class': 'cbi-value-field' }, optsIn) ]), E('div', { 'class': 'right', 'style': 'margin-top:1.5rem; display:flex; justify-content:flex-end; gap:8px;' }, [ E('button', { 'class': 'btn cbi-button cbi-button-reset', 'click': ui.hideModal }, _('Cancel')), E('button', { 'class': 'btn cbi-button cbi-button-add', 'click': function(ev) { ev.preventDefault(); var d = devSel.value, p = pointIn.value; if (!d || !p) { ui.addNotification(null, E('p', {}, _('Please select a device and input mount point!'))); return; } this.disabled = true; ui.showModal(_('Mounting'), [ E('p', { 'class': 'spinning' }, _('Mounting device...')) ]); callMount(d, p, fsSel.value, optsIn.value).then(function(res) { if (res && res.code !== 0) { ui.addNotification(null, E('p', {}, res.error || _('Failed to mount.'))); } location.reload(); }); } }, _('Mount')) ]) ]); }, // Create-partition dialog. Start sector is auto-detected (rounded UP to the // next 2048-sector / 1 MiB boundary after the last partition) and a size + // unit are converted to an end sector. The helper shows the max free space. showCreatePartitionModal: function(devName, info) { var secSize = (info && info.logic_sec) || 512; var totalSectors = (info && info.size) ? Math.floor(info.size / secSize) : 0; var maxUsable = totalSectors > 34 ? totalSectors - 34 : totalSectors; var parts = (info && info.partitions) || []; var GB = 1073741824, MB = 1048576; var maxEnd = -1; parts.forEach(function(p) { if ((p.sec_end | 0) > maxEnd) maxEnd = p.sec_end | 0; }); var autoStart = maxEnd >= 0 ? Math.ceil((maxEnd + 1) / 2048) * 2048 : 2048; var remainBytes = Math.max(0, maxUsable - autoStart + 1) * secSize; var remainFmt = formatBytes(remainBytes); var defaultUnit = 'GB', defaultVal = 1; if (remainBytes >= GB) { defaultUnit = 'GB'; defaultVal = Math.min(50, Math.max(1, Math.floor(remainBytes / GB))); } else if (remainBytes >= MB) { defaultUnit = 'MB'; defaultVal = Math.max(1, Math.floor(remainBytes / MB)); } var startIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': autoStart, 'style': 'width:140px;' }); var sizeIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': defaultVal, 'style': 'width:140px;' }); var unitSel = E('select', { 'class': 'cbi-input-select', 'style': 'width:100px;' }, [ E('option', { 'value': 'TB' }, 'TB'), E('option', { 'value': 'GB' }, 'GB'), E('option', { 'value': 'MB' }, 'MB'), E('option', { 'value': 'sectors' }, _('sectors')) ]); unitSel.value = defaultUnit; var helper = E('div', { 'class': 'cbi-value-description' }, _('Maximum available: %s').format(remainFmt)); ui.showModal(_('Create Partition'), [ E('p', { 'style': 'margin:0 0 1rem 0;' }, _('New partition on /dev/%s. Start sector auto-detected and aligned to 1 MiB.').format(devName)), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Start Sector')), E('div', { 'class': 'cbi-value-field' }, startIn) ]), E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('Partition Size')), E('div', { 'class': 'cbi-value-field' }, [ sizeIn, unitSel, helper ]) ]), E('div', { 'class': 'right', 'style': 'margin-top:1.5rem; display:flex; justify-content:flex-end; gap:8px;' }, [ E('button', { 'class': 'btn cbi-button cbi-button-reset', 'click': ui.hideModal }, _('Cancel')), E('button', { 'class': 'btn cbi-button cbi-button-apply', 'click': function(ev) { ev.preventDefault(); var start = parseInt(startIn.value, 10); var sizeVal = parseFloat(sizeIn.value); var unit = unitSel.value; if (isNaN(start) || start <= 0) { ui.addNotification(null, E('p', {}, _('Invalid Start sector!'))); return; } if (isNaN(sizeVal) || sizeVal <= 0) { ui.addNotification(null, E('p', {}, _('Invalid Partition size!'))); return; } var unitBytes = { 'TB': 1099511627776, 'GB': GB, 'MB': MB, 'sectors': secSize }[unit]; var sectors = Math.max(1, Math.round((sizeVal * unitBytes) / secSize)); var end = start + sectors - 1; if (end > maxUsable) { ui.addNotification(null, E('p', {}, _('Size exceeds the available free space.'))); return; } this.disabled = true; ui.showModal(_('Creating Partition'), [ E('p', { 'class': 'spinning' }, _('Creating new partition...')) ]); callCreatePartition(devName, start, end, 'primary').then(function(res) { if (res && res.code !== 0) { ui.addNotification(null, E('p', {}, res.error || _('Failed to create partition.'))); } location.reload(); }); } }, _('Create')) ]) ]); }, // --- shared builders ---------------------------------------------------- // Colored, underlined SMART/health link (open the attributes modal on click). buildSmartLink: function(devName, health) { var raw = (health || '').trim().replace(/[-\s]+$/, '').trim(); var cls = 'passed'; if (raw && (raw.indexOf('Warning') !== -1 || raw.indexOf('Urgent') !== -1 || raw.indexOf('FAILED') !== -1)) cls = 'failed'; else if (raw && raw.indexOf('Normal') !== -1) cls = 'passed'; return E('a', { 'href': '#', 'class': 'smart-status-link ' + cls, 'title': _('Click to view SMART & Health details'), 'click': function(ev) { ev.preventDefault(); this.showSmartModal(devName); }.bind(this) }, translateHealth(raw) || '-'); }, // Bus/transport badge inferred from the device path. busBadge: function(dev) { var p = (dev.path || '').toLowerCase(); if (p.indexOf('nvme') !== -1) return 'NVMe'; if (p.indexOf('mmcblk') !== -1) return 'eMMC'; if (p.indexOf('md') !== -1) return 'RAID'; return 'SATA'; }, // The 5-state legend strip. buildLegend: function() { var items = [ ['part-state-boot', _('Boot / Firmware (protected)')], ['part-state-inuse', _('Data (in use)')], ['part-state-unmounted', _('Data (unmounted)')], ['part-state-raw', _('Empty (no filesystem)')], ['part-state-unalloc', _('Unallocated Space')] ]; return E('div', { 'class': 'disk-partition-legend' }, items.map(function(it) { return E('span', { 'class': 'legend-item' }, [ E('span', { 'class': 'legend-color-dot ' + it[0] }), it[1] ]); })); }, // The 14px pill partition bar. flex-grow still reflects relative size so a // big data partition dwarfs the boot slivers, but each segment carries a CSS // min-width so the partition name always fits — proportions stay // approximate, legibility stays hard. Full detail via the hover tooltip. buildPillBar: function(dev, parts) { var totalSize = dev.size || 1; var used = 0; var segs = []; function makeSeg(cls, pct, tipText, nameLabel) { var grow = Math.max(1, Math.round(pct)); return E('div', { 'class': 'part-segment ' + cls, 'style': 'flex:' + grow + ' 1 0;' }, [ nameLabel, E('span', { 'class': 'part-tooltip' }, tipText) ]); } if (!parts || parts.length === 0) { var st = partState({ is_boot: dev.is_system, mount_point: dev.mount_point, fstype: dev.fstype }); segs.push(makeSeg(st.cls, 100, dev.name + ' - ' + st.tag, dev.name)); } else { parts.forEach(function(p) { used += (p.size || 0); var st = partState(p); var pct = (p.size / totalSize) * 100; var tip = p.name + ' (' + (p.size_formated || '') + ') - ' + st.tag + (p.fstype ? ' · ' + p.fstype : '') + (p.mount_point && p.mount_point !== '-' ? ' -> ' + p.mount_point : ''); segs.push(makeSeg(st.cls, pct, tip, p.name)); }); var unalloc = totalSize - used; if (unalloc > totalSize * 0.015 && unalloc > 0) { var upct = (unalloc / totalSize) * 100; segs.push(makeSeg('part-state-unalloc', upct, _('Unallocated') + ' (' + formatBytes(unalloc) + ')', _('Unallocated'))); } } return E('div', { 'class': 'disk-partition-bar' }, segs); }, // Compact overview card (physical-disks tab): header + pill bar. buildDiskPartitionCard: function(dev) { var self = this; var parts = dev.partitions || []; var card = E('div', { 'class': 'disk-partition-card' }); card.appendChild(E('div', { 'class': 'disk-part-card-header' }, [ E('div', { 'class': 'disk-part-card-title' }, [ E('span', { 'class': 'disk-bus-badge' }, this.busBadge(dev)), E('span', { 'class': 'disk-part-path' }, dev.path), E('span', { 'class': 'disk-part-model' }, dev.model || '') ]), E('span', { 'class': 'disk-part-spec' }, (dev.size_formated || '') + (dev.p_table && dev.p_table !== '-' ? ' · ' + dev.p_table : '')) ])); card.appendChild(E('div', { 'class': 'disk-part-card-meta' }, [ E('span', { 'class': 'disk-meta-tag' }, [_('Partitions: %d').format(parts.length)]), E('span', { 'class': 'disk-meta-tag' }, [_('Serial: %s').format(dev.sn || '-')]) ])); card.appendChild(this.buildPillBar(dev, parts)); return card; }, // Detailed per-disk section (partition-management tab): header + bar/chips + // master partition table + bottom action bar. All protection rules apply. buildDiskCard: function(dev, info, data) { var self = this; var devName = dev.name; var mountPoints = data[1] || []; var formatCmds = data[4] || {}; var parts = (info && info.partitions) || (dev.partitions) || []; var isSystem = !!(dev.is_system || (info && info.is_system)); var anyMounted = parts.some(function(p) { return p.mount_point && p.mount_point !== '-'; }); var hasPartitions = parts.some(function(p) { return p.number > 0; }); var pTableVal = (info && info.p_table) || (dev.p_table || ''); var card = E('fieldset', { 'class': 'disk-card-section', 'id': 'disk-group-' + devName }); // Header (legend): identity left, size/table/SMART right. card.appendChild(E('legend', {}, [ E('span', { 'style': 'display:flex; align-items:center; gap:8px; flex-wrap:wrap;' }, [ E('span', { 'class': 'disk-bus-badge' }, this.busBadge(dev)), E('span', { 'style': 'font-size:14px; font-weight:600; color:var(--text-color-highest);' }, dev.path), E('span', { 'class': 'disk-part-model' }, dev.model || '') ]), E('span', { 'style': 'display:flex; align-items:center; gap:12px; font-size:12px; color:var(--text-color-medium);' }, [ ((info && info.size_formated) || (dev.size_formated || '')), ((info && info.p_table) || (dev.p_table || '')), this.buildSmartLink(devName, (info && info.health) || (dev.health_status)) ]) ])); // Pill bar only (the chips row was removed: the master partition table // below already lists name / size / fs / mount for every partition). var barWrap = E('div', { 'class': 'disk-card-bar-wrap' }); barWrap.appendChild(this.buildPillBar(dev, parts)); card.appendChild(barWrap); // Master partition table var optByDev = {}; mountPoints.forEach(function(mp) { optByDev[mp.device] = mp.mount_options; }); var partTable = E('table', { 'class': 'table cbi-section-table disk-card-table', 'style': 'min-width:1080px;' }, [ E('colgroup', {}, [ E('col', { 'style': 'width:11%;' }), E('col', { 'style': 'width:13%;' }), E('col', { 'style': 'width:16%;' }), E('col', { 'style': 'width:8%;' }), E('col', { 'style': 'width:9%;' }), E('col', { 'style': 'width:15%;' }), E('col', { 'style': 'width:7%;' }), E('col', { 'style': 'width:21%;' }) ]), E('tr', { 'class': 'tr table-titles' }, [ E('th', { 'class': 'th' }, _('Device')), E('th', { 'class': 'th' }, _('File System')), E('th', { 'class': 'th' }, _('Mount Point')), E('th', { 'class': 'th' }, _('Size')), E('th', { 'class': 'th' }, _('Usage')), E('th', { 'class': 'th' }, _('Mount Options')), E('th', { 'class': 'th' }, _('Auto Mount')), E('th', { 'class': 'th' }, _('Actions')) ]) ]); parts.forEach(function(part) { var isFree = (part.number === -1 || part.fs === 'Free Space' || part.type === 'free'); var isMounted = part.mount_point && part.mount_point !== '-'; var isSystemPart = !!part.is_boot; var isExtended = (part.type === 'extended' || part.fs === 'extended'); var opt = optByDev[part.path] || '—'; if (isFree) { // Small firmware slivers (< 64 MiB) are not worth carving a new // partition on, so we don't offer a "New" button on them. var canCreate = (part.size || 0) >= MIN_MOUNT_BYTES; partTable.appendChild(E('tr', { 'class': 'tr' }, [ E('td', { 'class': 'td', 'style': 'font-style:italic; color:var(--text-color-medium);' }, _('Free Space')), E('td', { 'class': 'td' }, part.size_formated || '-'), E('td', { 'class': 'td' }, '—'), E('td', { 'class': 'td' }, part.size_formated || '-'), E('td', { 'class': 'td' }, '—'), E('td', { 'class': 'td' }, '—'), E('td', { 'class': 'td' }, '—'), E('td', { 'class': 'td cbi-section-actions' }, canCreate ? E('button', { 'class': 'btn cbi-button cbi-button-add', 'title': _('Create a partition in this free region'), 'click': function(ev) { ev.preventDefault(); self.showCreatePartitionModal(devName, info); } }, _('New')) : E('span', { 'class': 'label label-cbi-state-disabled' }, _('Unallocated'))) ])); return; } var fsCell = E('span', { 'title': isSystemPart ? _('System partition, protected') : null }, translateFs(part.fs, part.type)); // Unmounted non-system partitions show a plain dash in the mount-point // column - state is already obvious from the FS column and the pill bar. // System/boot segments on a FIT-based eMMC are consumed by the bootloader // and kernel (e.g. the FIT image) and are never mounted as a filesystem; // a bare "—" here read as "unused", so spell the real role out instead. var mountCell; if (isMounted) { mountCell = [ E('span', { 'class': 'badge badge-status badge-mounted' }, _('Mounted')), ' ', E('code', {}, part.mount_point) ]; } else if (isSystemPart) { mountCell = E('span', { 'style': 'font-style:italic; color:var(--text-color-medium);', 'title': _('Loaded by the bootloader and kernel from the FIT image; not mounted as a separate filesystem.') }, _('Kernel-managed (FIT)')); } else { mountCell = '—'; } var usageCell = (isMounted && part.usage) ? E('div', { 'style': 'display:flex; align-items:center; gap:6px;' }, [ E('div', { 'style': 'width:60px; height:6px; background:var(--background-color-medium); border-radius:3px; overflow:hidden;' }, [ E('div', { 'style': 'width:' + part.usage + '; height:100%; background:var(--primary-color-high);' }) ]), E('span', { 'style': 'font-size:11px; color:var(--text-color-medium);' }, part.usage) ]) : '—'; // Unmounted partitions show a plain dash in the auto-mount column - // labelling an unmounted partition "Disabled" read as contradictory. var autoCell = isMounted ? E('span', { 'style': 'color:var(--success-color-high); font-size:12px;' }, _('Enabled')) : '—'; var actions = []; if (isSystemPart) { // System partitions are not operable at all. actions.push(E('span', { 'class': 'label label-cbi-state-disabled' }, _('System partition, protected'))); } else { var hasLogicals = parts.some(function(p) { return p.type === 'logical'; }); if (isMounted) { actions.push(E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'title': _('Detach this partition from its mount point; data is kept'), 'click': function(ev) { ev.preventDefault(); self.showConfirm(_('Unmount'), _('Are you sure you want to unmount %s?').format(part.mount_point), function() { ui.showModal(_('Unmounting'), [ E('p', { 'class': 'spinning' }, _('Unmounting %s...').format(part.mount_point)) ]); callUmount(part.mount_point).then(function(res) { if (res && res.code !== 0) { ui.addNotification(null, E('p', {}, res.error || _('Failed to unmount.'))); } location.reload(); }); }); } }, _('Unmount'))); actions.push(E('button', { 'class': 'btn cbi-button cbi-button-edit', 'title': _('Change mount point / options'), 'click': function(ev) { ev.preventDefault(); self.showMountModal(devName, dev, info, part, data); } }, _('Edit Mount'))); } else { // Unmounted: Mount + Format (non-extended); Delete unless it's // an extended container still holding logicals. if (!isExtended) { actions.push(E('button', { 'class': 'btn cbi-button cbi-button-add', 'title': _('Mount this partition'), 'click': function(ev) { ev.preventDefault(); self.showMountModal(devName, dev, info, part, data); } }, _('Mount'))); } if (!isExtended) { actions.push(E('button', { 'class': 'btn cbi-button cbi-button-negative', 'title': _('Formatting erases all data on the partition'), 'click': function(ev) { ev.preventDefault(); self.showFormatModal(part.name, formatCmds, part.fs, function() { location.reload(); }); } }, _('Format'))); } if (!(isExtended && hasLogicals)) { actions.push(E('button', { 'class': 'btn cbi-button cbi-button-negative', 'title': _('Remove this partition from the table; its space becomes unallocated'), 'click': function(ev) { ev.preventDefault(); self.showConfirm(_('Delete Partition'), _('Are you sure you want to delete partition %s?').format(part.name), function() { ui.showModal(_('Deleting Partition'), [ E('p', { 'class': 'spinning' }, _('Removing partition...')) ]); callRemovePartition(devName, part.number).then(function(res) { if (res && res.code !== 0) { ui.addNotification(null, E('p', {}, res.error || _('Failed to remove partition.'))); } location.reload(); }); }); } }, _('Delete'))); } } } partTable.appendChild(E('tr', { 'class': 'tr' }, [ E('td', { 'class': 'td col-device' }, E('strong', {}, part.name || ('/' + devName + part.number))), E('td', { 'class': 'td col-fs' }, fsCell), E('td', { 'class': 'td col-mount' }, mountCell), E('td', { 'class': 'td' }, part.size_formated || '-'), E('td', { 'class': 'td' }, usageCell), E('td', { 'class': 'td' }, E('span', { 'class': 'mount-options-text', 'title': opt }, opt)), E('td', { 'class': 'td col-automount' }, autoCell), E('td', { 'class': 'td cbi-section-actions' }, [ E('div', { 'class': 'btn-group-actions' }, actions) ]) ])); }); // (The inline quick-mount row was removed: every unmounted partition row // already has a Mount button that opens showMountModal, which lists all // mountable partitions on this disk — the inline row duplicated that.) card.appendChild(partTable); // Bottom action bar var left = E('div', { 'class': 'disk-actions-left' }); var right = E('div', { 'class': 'disk-actions-right' }); if (!isSystem) { left.appendChild(E('button', { 'class': 'btn cbi-button cbi-button-add', 'title': _('Create a new partition from the largest free region'), 'click': function(ev) { ev.preventDefault(); self.showCreatePartitionModal(devName, info); } }, _('New Partition'))); left.appendChild(E('button', { 'class': 'btn cbi-button cbi-button-negative', 'disabled': anyMounted || null, 'click': function(ev) { ev.preventDefault(); if (anyMounted) { ui.addNotification(null, E('p', {}, _('Device is in use! Please unmount all partitions first!'))); return; } self.showConfirm(_('Eject Device'), _('Are you sure you want to eject/remove this device?'), function() { ui.showModal(_('Ejecting'), [ E('p', { 'class': 'spinning' }, _('Ejecting device...')) ]); callEjectDevice(devName).then(function(res) { if (res && res.code !== 0) { ui.addNotification(null, E('p', {}, res.error || _('Failed to eject device.'))); } location.reload(); }); }); } }, _('Eject'))); } // The backend's mk_partition_table is `parted mklabel` (destructive wipe), // so it is only offered on a disk with NO partitions yet - never on a disk // that already has a layout (protects the eMMC / any real disk). if (!hasPartitions && !isSystem && pTableVal && pTableVal.indexOf('Raid') === -1) { var ptSel = E('select', { 'class': 'cbi-input-select' }, [ E('option', { 'value': 'GPT' }, 'GPT'), E('option', { 'value': 'MBR' }, 'MBR') ]); right.appendChild(ptSel); right.appendChild(E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'title': _('Create a fresh GPT/MBR partition table'), 'click': function(ev) { ev.preventDefault(); var label = ptSel.value === 'MBR' ? 'MBR' : 'GPT'; self.showConfirm(_('Change Partition Table'), _('Warning !!\nTHIS WILL OVERWRITE EXISTING PARTITIONS!!\nModify the partition table to %s?').format(label), function() { ui.showModal(_('Modifying Partition Table'), [ E('p', { 'class': 'spinning' }, _('Applying partition table...')) ]); callMkPartitionTable(devName, ptSel.value).then(function(res) { if (res && res.code !== 0) { ui.addNotification(null, E('p', {}, res.error || _('Failed to modify partition table.'))); } location.reload(); }); }); } }, _('Apply Table'))); } card.appendChild(E('div', { 'class': 'disk-card-actions-bar' }, [ E('div', { 'class': 'disk-actions-flex' }, [ left, right ]) ])); return card; }, // --- RAID ----------------------------------------------------------------- buildRaidForm: function(data) { var self = this; var devices = data[0] || {}; var form = E('fieldset', { 'class': 'cbi-section disk-card-section' }); form.appendChild(E('legend', {}, [ E('span', {}, [ _('Create RAID'), E('span', { 'style': 'font-size:12px; font-weight:normal; color:var(--text-color-medium); margin-left:8px;' }, '(' + _('Select two or more free partitions to build a high-reliability or high-performance array') + ')') ]) ])); var node = E('div', { 'class': 'cbi-section-node' }); var nameIn = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': '/dev/md1', 'style': 'width:240px;' }); var levelSel = E('select', { 'class': 'cbi-input-select', 'style': 'min-width:320px;' }, [ E('option', { 'value': '1', 'selected': true }, 'RAID 1 ' + _('(Mirror - recommended dual-disk data fault tolerance, doubled read speed)')), E('option', { 'value': '0' }, 'RAID 0 ' + _('(Stripe - high throughput, no redundancy, full capacity)')), E('option', { 'value': '5' }, 'RAID 5 ' + _('(Distributed parity redundancy - needs at least 3 partitions)')), E('option', { 'value': '6' }, 'RAID 6 ' + _('(Double parity fault tolerance - needs at least 4 partitions)')), E('option', { 'value': '10' }, 'RAID 10 ' + _('(Mirror + Stripe hybrid - performance with high fault tolerance)')) ]); var chipGroup = buildMemberChips(devices, _('No free partitions available to select.')); node.appendChild(E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('RAID Name')), E('div', { 'class': 'cbi-value-field' }, [ nameIn, E('div', { 'class': 'cbi-value-description', 'style': 'font-size:11px; color:var(--text-color-medium); margin-top:3px;' }, _('Standard software RAID block device path, defaults to /dev/md1, /dev/md2...')) ]) ])); node.appendChild(E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('RAID Level')), E('div', { 'class': 'cbi-value-field' }, levelSel) ])); node.appendChild(E('div', { 'class': 'cbi-value' }, [ E('label', { 'class': 'cbi-value-title' }, _('RAID Members')), E('div', { 'class': 'cbi-value-field' }, [ chipGroup, E('div', { 'class': 'cbi-value-description', 'style': 'font-size:11px; color:var(--text-color-medium); margin-top:4px;' }, _('All currently unmounted free storage partitions are listed automatically.')) ]) ])); form.appendChild(node); var createBtn = E('button', { 'class': 'btn cbi-button cbi-button-action important', 'click': function(ev) { ev.preventDefault(); var members = []; var boxes = chipGroup.querySelectorAll('input[type="checkbox"]'); for (var i = 0; i < boxes.length; i++) { if (boxes[i].checked) members.push(boxes[i].value); } if (members.length < 2) { ui.addNotification(null, E('p', {}, _('Select at least two RAID members!'))); return; } self.showConfirm(_('Create RAID'), _('Creating a RAID array will ERASE the selected members. Continue?'), function() { ui.showModal(_('Creating RAID'), [ E('p', { 'class': 'spinning' }, _('Creating RAID device...')) ]); callCreateRaid(nameIn.value, levelSel.value, members).then(function(res) { if (res && res.code !== 0) { ui.addNotification(null, E('p', {}, res.error || _('Failed to create RAID.'))); } location.reload(); }); }); } }, _('Create RAID')); var resetBtn = E('button', { 'class': 'btn cbi-button cbi-button-neutral', 'click': function(ev) { ev.preventDefault(); if (typeof currentDraw === 'function') currentDraw(); } }, _('Reset')); form.appendChild(E('div', { 'class': 'cbi-page-actions', 'style': 'margin-top:16px; display:flex; gap:10px;' }, [ createBtn, resetBtn ])); return form; }, // --- Btrfs ---------------------------------------------------------------- buildBtrfsCard: function(pool, info, subs, data) { var self = this; var uuid = pool.uuid; var mountPoints = data[1] || []; var members = (pool.members || '').split(/\s*,\s*/).filter(Boolean); var mp = btrfsMountPoint(mountPoints, pool.members); var infoObj = info || {}; var usagePct = parseFloat(infoObj.usage) || 0; var card = E('fieldset', { 'class': 'cbi-section disk-card-section btrfs-pool-card' }); // legend: Btrfs: