mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-09-11 02:44:23 +08:00
921 lines
35 KiB
JavaScript
921 lines
35 KiB
JavaScript
/*
|
|
* Copyright (C) 2022-2025 Sirpdboy <herboy2008@gmail.com>
|
|
*
|
|
* 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 = [
|
|
'<style>',
|
|
'.state-ctl .state { display: none !important; }',
|
|
'.state-ctl.state-ctl-ready .state.state-ready,',
|
|
'.state-ctl.state-ctl-executing .state.state-executing {',
|
|
' display: block !important;',
|
|
'}',
|
|
'.progress-container {',
|
|
' width: 100%;',
|
|
' height: 20px;',
|
|
' background: rgba(0,0,0,0.2);',
|
|
' border-radius: 10px;',
|
|
' display: inline-block;',
|
|
' margin: 10px 0;',
|
|
' vertical-align: middle;',
|
|
' position: relative;',
|
|
' overflow: hidden;',
|
|
'}',
|
|
'.progress-bar {',
|
|
' height: 100%;',
|
|
' background: linear-gradient(90deg, #4CAF50, #8BC34A);',
|
|
' transition: width 0.3s ease-out;',
|
|
' position: absolute;',
|
|
' left: 0;',
|
|
' top: 0;',
|
|
'}',
|
|
'.progress-text {',
|
|
' position: absolute;',
|
|
' width: 100%;',
|
|
' text-align: center;',
|
|
' line-height: 20px;',
|
|
' font-size: 12px;',
|
|
' font-weight: bold;',
|
|
' color: #eee;',
|
|
' z-index: 1;',
|
|
' text-shadow: 1px 1px 2px rgba(0,0,0,0.5);',
|
|
'}',
|
|
'.error-message {',
|
|
' color: #dc3545;',
|
|
'}',
|
|
'.info-note {',
|
|
' padding: 10px;',
|
|
' margin: 10px 0;',
|
|
' border-radius: 4px;',
|
|
'}',
|
|
'.log-view {',
|
|
' font-family: "Courier New", monospace;',
|
|
' font-size: 12px;',
|
|
' height: 300px;',
|
|
' overflow-y: auto;',
|
|
' white-space: pre-wrap;',
|
|
'}',
|
|
'</style>',
|
|
'<h2 name="content">' + _('One click partition expansion mounting tool') + '</h2>',
|
|
'<div class="cbi-section-descr">',
|
|
' <div class="info-note">',
|
|
' ' + _('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.') + '<br>',
|
|
' ' + _('For specific usage, see:') + ' ',
|
|
' <a href="https://github.com/sirpdboy/luci-app-partexp.git" target="_blank">',
|
|
' GitHub @partexp',
|
|
' </a>',
|
|
' </div>',
|
|
'</div>',
|
|
'<div class="state-ctl state-ctl-ready" id="state-container">',
|
|
' <div class="cbi-section cbi-section-node">',
|
|
' <div class="state state-ready">',
|
|
' <form id="partexp-form">',
|
|
' <div class="cbi-value">',
|
|
' <label class="cbi-value-title" for="target_function">' + _('Select function') + '</label>',
|
|
' <div class="cbi-value-field">',
|
|
' <select class="cbi-input-select" id="target_function" name="target_function">',
|
|
' <option value="/">' + _('Used to extend to the root directory of EXT4 firmware(Ext4 /)') + '</option>',
|
|
' <option value="/overlay">' + _('Expand application space overlay (/overlay)') + '</option>',
|
|
' <option value="/opt">' + _('Used as Docker data disk (/opt)') + '</option>',
|
|
' <option value="/mnt">' + _('Normal mount and use by device name(/mnt/x1)') + '</option>',
|
|
' </select>',
|
|
' <div class="cbi-value-description">' + _('Select the function to be performed') + '</div>',
|
|
' </div>',
|
|
' </div>',
|
|
' <div class="cbi-value">',
|
|
' <label class="cbi-value-title" for="target_disk">' + _('Destination hard disk') + '</label>',
|
|
' <div class="cbi-value-field">',
|
|
' <select class="cbi-input-select" id="target_disk" name="target_disk">',
|
|
' <option value="">' + _('Loading devices...') + '</option>',
|
|
' </select>',
|
|
' <div class="cbi-value-description">' + _('Select the hard disk device to operate') + '</div>',
|
|
' </div>',
|
|
' </div>',
|
|
' <div class="cbi-value">',
|
|
' <label class="cbi-value-title" for="keep_config">' + _('Keep configuration') + '</label>',
|
|
' <div class="cbi-value-field">',
|
|
' <input type="checkbox" class="cbi-input-checkbox" id="keep_config" name="keep_config" value="1" />',
|
|
' <label for="keep_config">' + _('Tick means to retain the settings') + '</label>',
|
|
' </div>',
|
|
' </div>',
|
|
' <div class="cbi-value">',
|
|
' <label class="cbi-value-title" for="format_type">' + _('Format system type') + '</label>',
|
|
' <div class="cbi-value-field">',
|
|
' <select class="cbi-input-select" id="format_type" name="format_type">',
|
|
' <option value="0">' + _('No formatting required') + '</option>',
|
|
' <option value="ext4">' + _('Linux system partition(EXT4)') + '</option>',
|
|
' <option value="btrfs">' + _('Large capacity storage devices(Btrfs)') + '</option>',
|
|
' <option value="ntfs">' + _('Windows system partition(NTFS)') + '</option>',
|
|
' </select>',
|
|
' </div>',
|
|
' </div>',
|
|
' <div class="cbi-value cbi-value-last">',
|
|
' <label class="cbi-value-title">' + _('Perform operation') + '</label>',
|
|
' <div class="cbi-value-field">',
|
|
' <button type="button" class="cbi-button cbi-button-apply" id="execute-btn">',
|
|
' ' + _('Click to execute') + '',
|
|
' </button>',
|
|
' </div>',
|
|
' </div>',
|
|
' </form>',
|
|
' </div>',
|
|
' <div class="state state-executing">',
|
|
' <div class="cbi-value">',
|
|
' <label class="cbi-value-title" id="execute_status">' + _('Starting operation...') + '</label>',
|
|
' <div class="cbi-value-field">',
|
|
' <div class="progress-container">',
|
|
' <div id="progress-bar" class="progress-bar" style="width: 0%"></div>',
|
|
' <div id="progress-text" class="progress-text">0%</div>',
|
|
' </div>',
|
|
' </div>',
|
|
' </div>',
|
|
' </div>',
|
|
' </div>',
|
|
' <div id="log-section" style="display: block; margin-top: 20px;">',
|
|
' <div class="cbi-value">',
|
|
' <label class="cbi-value-title">' + _('Operation Log') + '</label>',
|
|
' <div class="cbi-value-field">',
|
|
' <textarea id="log-view" class="log-view" readonly="readonly" rows="15"></textarea>',
|
|
' </div>',
|
|
' </div>',
|
|
' </div>',
|
|
'</div>'
|
|
];
|
|
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
|
|
}); |