/* * Copyright (C) 2022-2025 Sirpdboy * * Licensed to the public under the Apache License 2.0 */ 'use strict'; 'require form'; 'require fs'; 'require rpc'; 'require uci'; 'require ui'; 'require view'; // 声明 RPC 接口 var callPartExpAutopart = rpc.declare({ object: 'partexp', method: 'autopart' }); var callPartExpGetLog = rpc.declare({ object: 'partexp', method: 'get_log', params: ['position'] }); var callPartExpGetDevices = rpc.declare({ object: 'partexp', method: 'get_devices' }); var callPartExpGetStatus = rpc.declare({ object: 'partexp', method: 'get_status' }); // 添加保存配置的 RPC 声明 var callPartExpSaveConfig = rpc.declare({ object: 'partexp', method: 'save_config', params: ['target_function', 'target_disk', 'keep_config', 'format_type'] }); return view.extend({ load: function() { return Promise.all([ L.resolveDefault(fs.stat('/usr/bin/partexp'), null), L.resolveDefault(fs.stat('/tmp/partexp.log'), null) ]); }, render: function(data) { var container = E('div', { class: 'cbi-map' }); var htmlParts = [ '', '

' + _('One click partition expansion mounting tool') + '

', '
', '
', ' ' + _('Automatically format and mount the target device partition. If there are multiple partitions, it is recommended to manually delete all partitions before using this tool.') + '
', ' ' + _('For specific usage, see:') + ' ', ' ', ' GitHub @partexp', ' ', '
', '
', '
', '
', '
', '
', '
', ' ', '
', ' ', '
' + _('Select the function to be performed') + '
', '
', '
', '
', ' ', '
', ' ', '
' + _('Select the hard disk device to operate') + '
', '
', '
', '
', ' ', '
', ' ', ' ', '
', '
', '
', ' ', '
', ' ', '
', '
', '
', ' ', '
', ' ', '
', '
', '
', '
', '
', '
', ' ', '
', '
', '
', '
0%
', '
', '
', '
', '
', '
', '
', '
', ' ', '
', ' ', '
', '
', '
', '
' ]; container.innerHTML = htmlParts.join(''); var self = this; var uci = self.uci || window.uci; setTimeout(function() { self.initDOM(); self.bindEvents(); self.loadDevices(); self.loadSavedConfig(); self.checkOperationStatus(); self.loadExistingLog(); }, 100); return container; }, initDOM: function() { this.dom = { stateContainer: document.querySelector('#state-container'), targetFunction: document.querySelector('#target_function'), targetDisk: document.querySelector('#target_disk'), keepConfig: document.querySelector('#keep_config'), formatType: document.querySelector('#format_type'), executeBtn: document.querySelector('#execute-btn'), logView: document.querySelector('#log-view'), progressBar: document.querySelector('#progress-bar'), progressText: document.querySelector('#progress-text'), executeStatus: document.querySelector('#execute_status') }; this.logPosition = '0'; this.logPolling = null; this.isRunning = false; this.operationComplete = false; this.pollErrorCount = 0; this.pollingStartTime = 0; this.lastPollTime = 0; this.currentProgress = 0; this.autoSaveTimer = null; this.isNewOperation = false; // 标记是否是新操作 }, bindEvents: function() { var self = this; if (this.dom.executeBtn) { this.dom.executeBtn.addEventListener('click', function(e) { e.preventDefault(); self.executeOperation(); }); } [this.dom.targetFunction, this.dom.targetDisk, this.dom.formatType].forEach(function(element) { if (element) { element.addEventListener('change', function() { self.autoSaveConfig(); self.updateFormVisibility(); }); } }); if (this.dom.keepConfig) { this.dom.keepConfig.addEventListener('click', function() { self.autoSaveConfig(); }); } if (this.dom.targetFunction) { this.updateFormVisibility(); } }, loadDevices: function() { var self = this; if (self.dom.targetDisk) { var loadingOption = document.createElement('option'); loadingOption.value = ''; loadingOption.textContent = _('Loading devices...'); loadingOption.disabled = true; loadingOption.selected = true; self.dom.targetDisk.innerHTML = ''; self.dom.targetDisk.appendChild(loadingOption); } function loadDevicesWithRetry(retryCount = 0) { callPartExpGetDevices().then(function(response) { if (!response) { throw new Error('Empty response'); } if (self.dom.targetDisk) { self.dom.targetDisk.innerHTML = ''; if (response.devices && response.devices.length > 0) { response.devices.forEach(function(device) { var option = document.createElement('option'); option.value = device.name; option.textContent = device.name + ' (' + device.dev + ', ' + device.size + ' MB)'; self.dom.targetDisk.appendChild(option); }); } else { var noDeviceOption = document.createElement('option'); noDeviceOption.value = ''; noDeviceOption.textContent = _('no find device'); noDeviceOption.disabled = true; noDeviceOption.selected = true; self.dom.targetDisk.appendChild(noDeviceOption); } } }).catch(function(error) { console.error('Failed to load devices:', error); if (retryCount < 3) { setTimeout(function() { loadDevicesWithRetry(retryCount + 1); }, 1000 * (retryCount + 1)); } else { if (self.dom.targetDisk) { self.dom.targetDisk.innerHTML = ''; var errorOption = document.createElement('option'); errorOption.value = ''; errorOption.textContent = _('load error'); errorOption.disabled = true; errorOption.selected = true; self.dom.targetDisk.appendChild(errorOption); } ui.addNotification({ title: _('load device error'), text: _('Failed to load devices:'), type: 'error', delay: 5000 }); } }); } loadDevicesWithRetry(); }, loadExistingLog: function() { var self = this; callPartExpGetLog('0').then(function(response) { if (response && response.log) { var logContent = response.log.toString().trim(); if (logContent && self.dom.logView) { self.dom.logView.value = logContent; setTimeout(function() { if (self.dom.logView && self.dom.logView.value) { self.dom.logView.scrollTop = self.dom.logView.scrollHeight; } }, 100); if (response.position) { self.logPosition = response.position; } if (!self.isRunning && logContent.includes('正在执行') && !logContent.includes('操作完成')) { self.isRunning = true; self.switchState('executing'); self.startLogPolling(); } } } }).catch(function(error) { console.error('Failed to load existing log:', error); }); }, loadSavedConfig: function() { var self = this; return fs.read('/etc/config/partexp').then(function(content) { if (!content) { self.setDefaultConfig(); return; } var lines = content.split('\n'); var config = {}; lines.forEach(function(line) { line = line.trim(); if (line.startsWith('option')) { var parts = line.split(/\s+/); if (parts.length >= 3) { var key = parts[1]; var value = parts.slice(2).join(' ').replace(/^['"]|['"]$/g, ''); config[key] = value; } } }); if (self.dom.targetFunction) { self.dom.targetFunction.value = config.target_function || '/opt'; } if (self.dom.targetDisk && config.target_disk) { setTimeout(function() { if (self.dom.targetDisk) { self.dom.targetDisk.value = config.target_disk; } }, 500); } if (self.dom.keepConfig) { self.dom.keepConfig.checked = (config.keep_config === '1'); } if (self.dom.formatType) { self.dom.formatType.value = config.format_type || '0'; } self.configCache = config; self.updateFormVisibility(); }).catch(function(error) { console.log('Failed to load config:', error); self.setDefaultConfig(); }); }, setDefaultConfig: function() { if (this.dom.targetFunction) { this.dom.targetFunction.value = '/opt'; } if (this.dom.formatType) { this.dom.formatType.value = '0'; } if (this.dom.keepConfig) { this.dom.keepConfig.checked = false; } this.updateFormVisibility(); this.configCache = { target_function: '/opt', target_disk: '', keep_config: '0', format_type: '0' }; }, autoSaveConfig: function() { var self = this; if (this.autoSaveTimer) { clearTimeout(this.autoSaveTimer); } this.autoSaveTimer = setTimeout(function() { self.saveCurrentConfig(); }, 1500); }, saveCurrentConfig: function() { var self = this; var targetFunction = this.dom.targetFunction ? this.dom.targetFunction.value : '/opt'; var targetDisk = this.dom.targetDisk ? this.dom.targetDisk.value : ''; var keepConfig = this.dom.keepConfig ? this.dom.keepConfig.checked : false; var formatType = this.dom.formatType ? this.dom.formatType.value : '0'; if (callPartExpSaveConfig) { return callPartExpSaveConfig( targetFunction, targetDisk, keepConfig ? '1' : '0', formatType ).then(function(response) { if (response && response.success) { self.configCache = { target_function: targetFunction, target_disk: targetDisk, keep_config: keepConfig ? '1' : '0', format_type: formatType }; return true; } else { console.warn('RPC save failed, falling back to file write'); return self.saveConfigToFile(targetFunction, targetDisk, keepConfig, formatType); } }).catch(function(error) { console.error('RPC save config error:', error); return self.saveConfigToFile(targetFunction, targetDisk, keepConfig, formatType); }); } else { return self.saveConfigToFile(targetFunction, targetDisk, keepConfig, formatType); } }, saveConfigToFile: function(targetFunction, targetDisk, keepConfig, formatType) { var configContent = [ '# Auto-generated by partexp', '', 'config global global', "\toption target_function '" + targetFunction + "'", "\toption target_disk '" + targetDisk + "'", "\toption keep_config '" + (keepConfig ? '1' : '0') + "'", "\toption format_type '" + formatType + "'", '' ].join('\n'); return fs.write('/etc/config/partexp', configContent).then(function() { console.log('Settings saved to file /etc/config/partexp'); return true; }).catch(function(error) { console.error('Failed to save settings to file:', error); return false; }); }, executeOperation: function() { var self = this; this.saveCurrentConfig(); var target_function = this.dom.targetFunction.value; var target_disk = this.dom.targetDisk.value; if (target_function !== '/' && (!target_disk || target_disk.trim() === '')) { alert(_('Please select a target disk')); return; } var confirmMessage = _('Are you sure you want to execute partition expansion?') + '\n\n' + _('Function:') + ' ' + this.getFunctionDescription(target_function) + '\n' + (target_function !== '/' ? _('Disk:') + ' ' + target_disk + '\n' : '') + (target_function === '/' || target_function === '/overlay' ? _('Keep config:') + ' ' + (this.dom.keepConfig.checked ? _('Yes') : _('No')) + '\n' : '') + (target_function === '/opt' || target_function === '/dev' ? _('Format type:') + ' ' + this.getFormatTypeDescription(this.dom.formatType.value) + '\n' : '') + '\n' + _('This operation may take several minutes.'); if (!confirm(confirmMessage)) { return; } this.resetOperationState(); this.isNewOperation = true; if (this.dom.logView) { this.dom.logView.value = _('正在启动操作...'); } if (this.dom.executeBtn) { this.dom.executeBtn.disabled = true; this.dom.executeBtn.textContent = _('Executing...'); } this.switchState('executing'); this.updateProgress(5, _('Starting operation...')); callPartExpAutopart() .then(function(response) { if (response && response.success) { self.isRunning = true; self.operationComplete = false; self.startLogPolling(); if (self.dom.executeStatus) { self.dom.executeStatus.textContent = _('Operation started successfully'); } } else { var errorMsg = response && response.message ? response.message : _('Operation failed'); self.handleOperationError(errorMsg); } }) .catch(function(error) { console.error('Operation failed:', error); self.handleOperationError(_('Failed to start operation:') + ' ' + (error.message || _('Unknown error'))); }); }, resetOperationState: function() { this.logPosition = '0'; this.isRunning = true; this.operationComplete = false; this.pollErrorCount = 0; this.pollingStartTime = Date.now(); this.lastPollTime = 0; this.currentProgress = 0; this.updateProgress(0, _('Starting operation...')); }, handleOperationError: function(errorMsg) { alert(errorMsg); if (this.dom.executeBtn) { this.dom.executeBtn.disabled = false; this.dom.executeBtn.textContent = _('Click to execute'); } this.switchState('ready'); this.stopLogPolling(); if (this.dom.logView) { var currentLog = this.dom.logView.value || ''; this.dom.logView.value = currentLog + '\n\n' + _('操作失败:') + ' ' + errorMsg; setTimeout(() => { if (this.dom.logView) { this.dom.logView.scrollTop = this.dom.logView.scrollHeight; } }, 100); } }, updateFormVisibility: function() { if (!this.dom.targetFunction || !this.dom.targetDisk || !this.dom.keepConfig || !this.dom.formatType) return; var func = this.dom.targetFunction.value; var diskDiv = this.dom.targetDisk.closest('.cbi-value'); var keepDiv = this.dom.keepConfig.closest('.cbi-value'); var formatDiv = this.dom.formatType.closest('.cbi-value'); if (!diskDiv || !keepDiv || !formatDiv) return; if (func === '/') { diskDiv.style.display = 'none'; formatDiv.style.display = 'none'; keepDiv.style.display = 'block'; } else if (func === '/overlay') { diskDiv.style.display = 'block'; formatDiv.style.display = 'none'; keepDiv.style.display = 'block'; } else { diskDiv.style.display = 'block'; formatDiv.style.display = 'block'; keepDiv.style.display = 'none'; } }, checkOperationStatus: function() { var self = this; callPartExpGetStatus().then(function(response) { if (response && response.running) { self.isRunning = true; self.switchState('executing'); self.startLogPolling(); if (self.dom.executeBtn) { self.dom.executeBtn.disabled = true; self.dom.executeBtn.textContent = _('Operation in progress...'); } if (self.dom.executeStatus) { self.dom.executeStatus.textContent = _('Operation in progress...'); } } }).catch(function(error) { console.error('Failed to check operation status:', error); }); }, startLogPolling: function() { var self = this; this.stopLogPolling(); this.pollErrorCount = 0; this.pollingStartTime = Date.now(); this.lastPollTime = 0; this.updateProgress(10, _('Operation in progress...')); this.logPolling = setInterval(function() { if (Date.now() - self.pollingStartTime > 20 * 60 * 1000) { console.error('Operation timeout'); self.stopLogPolling(); self.isRunning = false; if (self.dom.logView) { var currentLog = self.dom.logView.value || ''; self.dom.logView.value = currentLog + '\n\n[超时] 操作超过20分钟未完成,请检查系统'; setTimeout(() => { if (self.dom.logView) { self.dom.logView.scrollTop = self.dom.logView.scrollHeight; } }, 100); } self.switchState('ready'); if (self.dom.executeBtn) { self.dom.executeBtn.disabled = false; self.dom.executeBtn.textContent = _('Click to execute'); } return; } self.pollLog(); }, 3000); // 每3秒轮询一次,减少频率 }, pollLog: function() { var self = this; if (!this.isRunning) { this.stopLogPolling(); return; } var pollStartTime = Date.now(); callPartExpGetLog('0').then(function(response) { if (!response) { console.error('No response from log polling'); return; } if (pollStartTime < self.lastPollTime) { return; } self.lastPollTime = pollStartTime; if (response.log !== undefined) { var logContent = response.log.toString().trim(); if (response.position) { self.logPosition = response.position; } if (self.dom.logView) { if (logContent !== '') { self.dom.logView.value = logContent; setTimeout(function() { if (self.dom.logView && self.dom.logView.value) { self.dom.logView.scrollTop = self.dom.logView.scrollHeight; } }, 50); } } self.parseAndUpdateProgress(logContent); if (self.checkOperationComplete(logContent)) { self.handleOperationComplete(); } } if (response.complete) { self.handleOperationComplete(); } self.pollErrorCount = 0; }).catch(function(error) { console.error('Log polling error:', error); self.pollErrorCount = (self.pollErrorCount || 0) + 1; if (self.pollErrorCount > 5) { console.error('Too many polling errors, stopping'); self.stopLogPolling(); self.isRunning = false; self.switchState('ready'); if (self.dom.executeBtn) { self.dom.executeBtn.disabled = false; self.dom.executeBtn.textContent = _('Click to execute'); } if (self.dom.logView) { var currentLog = self.dom.logView.value || ''; self.dom.logView.value = currentLog + '\n\n[错误] 日志轮询失败,请刷新页面查看最新状态'; setTimeout(() => { if (self.dom.logView) { self.dom.logView.scrollTop = self.dom.logView.scrollHeight; } }, 100); } } }); }, checkOperationComplete: function(logText) { if (!logText) return false; var completeMarkers = [ '重启设备', '操作完成' ]; for (var i = 0; i < completeMarkers.length; i++) { if (logText.includes(completeMarkers[i])) { return true; } } return false; }, handleOperationComplete: function() { if (this.operationComplete) { return; } this.operationComplete = true; this.isRunning = false; this.isNewOperation = false; this.stopLogPolling(); if (this.dom.logView) { var currentLog = this.dom.logView.value || ''; if (!currentLog.includes('操作完成')) { this.dom.logView.value = currentLog; setTimeout(() => { if (this.dom.logView) { this.dom.logView.scrollTop = this.dom.logView.scrollHeight; } }, 100); } } this.updateProgress(100, _('Operation completed')); setTimeout(() => { if (this.dom.executeBtn) { this.dom.executeBtn.disabled = false; this.dom.executeBtn.textContent = _('Click to execute'); } setTimeout(() => { this.switchState('ready'); }, 3000); }, 2000); }, parseAndUpdateProgress: function(logText) { if (!logText || !this.dom.executeStatus) return; var percent = 0; var statusMessage = _('Operation in progress...'); if (logText.includes('100%') || logText.includes('操作完成') || logText.includes('扩容成功')) { percent = 100; statusMessage = _('Operation completed'); } else if ( logText.includes('错误') || logText.includes('error')) { return; } else if (logText.includes('分区扩容和挂载到') || logText.includes('正在挂载')) { percent = 90; statusMessage = _('Getting device information'); } else if (logText.includes('检测设备')) { percent = 60; statusMessage = _('Checking partition format'); } else if (logText.includes('开始检测目标')) { percent = 50; statusMessage = _('Checking target device'); } else if (logText.includes('定位到操作目标设备分区')) { percent = 40; statusMessage = _('Locating target partition'); } else if (logText.includes('目标盘') && logText.includes('有剩余空间')) { percent = 30; statusMessage = _('Checking free space'); } else if (logText.includes('操作功能')) { percent = 20; statusMessage = _('Starting operation'); } else if (logText.includes('开始执行') || logText.includes('Starting')) { percent = 10; statusMessage = _('Initializing...'); } if (percent > 0) { this.currentProgress = Math.max(this.currentProgress || 0, percent); } else { this.currentProgress = Math.min(90, (this.currentProgress || 0) + 1); } this.updateProgress(this.currentProgress, statusMessage); }, updateProgress: function(percent, message) { if (!this.dom.progressBar || !this.dom.progressText || !this.dom.executeStatus) { return; } percent = Math.max(0, Math.min(100, percent)); this.dom.progressBar.style.width = percent + '%'; this.dom.progressText.textContent = percent + '%'; this.dom.executeStatus.textContent = message; }, stopLogPolling: function() { if (this.logPolling) { clearInterval(this.logPolling); this.logPolling = null; } }, // 切换状态 switchState: function(to) { if (!this.dom.stateContainer) return; this.dom.stateContainer.classList.remove( 'state-ctl-ready', 'state-ctl-executing' ); this.dom.stateContainer.classList.add('state-ctl-' + to); }, getFunctionDescription: function(func) { switch(func) { case '/': return _('Extend to root directory'); case '/overlay': return _('Expand overlay'); case '/opt': return _('Docker data disk'); case '/dev': return _('Normal mount'); default: return func; } }, getFormatTypeDescription: function(type) { switch(type) { case '0': return _('No formatting'); case 'ext4': return _('EXT4'); case 'btrfs': return _('Btrfs'); case 'ntfs': return _('NTFS'); default: return type; } }, handleSaveApply: null, handleSave: null, handleReset: null });