mirror of
https://github.com/StuffAnThings/qbit_manage.git
synced 2025-09-06 21:24:28 +08:00
# Requirements Updated - "GitPython==3.1.45" - "retrying==1.4.1", # New Features - **Remove Orphaned**: Adds new `min_file_age_minutes` flag to prevent files newer than a certain time from being deleted (Thanks to @H2OKing89 #859) - Adds new standalone script `ban_peers.py` for banning selected peers (Thanks to @tboy1337 #888) # Improvements - Adds timeout detectiono for stuck runs for web API rqeeusts # Bug Fixes - Fix bug in webUI deleting nohardlink section (Fixes #884) **Full Changelog**: https://github.com/StuffAnThings/qbit_manage/compare/v4.5.1...v4.5.2 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: cat-of-wisdom <217637421+cat-of-wisdom@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Quentin <qking.dev@gmail.com> Co-authored-by: ineednewpajamas <73252768+ineednewpajamas@users.noreply.github.com> Co-authored-by: tboy1337 <30571311+tboy1337@users.noreply.github.com> Co-authored-by: tboy1337 <tboy1337.unchanged733@aleeas.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1203 lines
45 KiB
JavaScript
Executable file
1203 lines
45 KiB
JavaScript
Executable file
/**
|
|
* qBit Manage Web UI - Main Application
|
|
* Modern configuration management interface for qBit Manage
|
|
*/
|
|
|
|
import { API } from './api.js';
|
|
import { ConfigForm } from './components/config-form.js';
|
|
import { CommandPanel } from './components/command-panel.js';
|
|
import { LogViewer } from './components/log-viewer.js';
|
|
import { get, query, queryAll, show, hide, showLoading, hideLoading } from './utils/dom.js';
|
|
import { initModal, showModal, hideModal } from './utils/modal.js';
|
|
import { showToast } from './utils/toast.js';
|
|
import { debounce } from './utils/utils.js';
|
|
import { HistoryManager } from './utils/history-manager.js';
|
|
import { themeManager } from './utils/theme-manager.js';
|
|
|
|
class QbitManageApp {
|
|
constructor() {
|
|
this.api = new API();
|
|
this.currentConfig = null;
|
|
this.currentSection = 'commands';
|
|
this.configData = {};
|
|
this.initialConfigData = {}; // Store initial loaded config for dirty checking
|
|
this.validationState = {};
|
|
this.isDirty = false;
|
|
|
|
// Component instances
|
|
this.configForm = null;
|
|
this.commandPanel = null;
|
|
this.logViewer = null;
|
|
this.helpModal = null; // To store the reference to the help modal
|
|
this.historyManager = new HistoryManager(this.api); // Initialize history manager
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
try {
|
|
// Theme manager is automatically initialized on import
|
|
|
|
// Initialize modal system
|
|
initModal();
|
|
|
|
// Fetch app configuration including base URL
|
|
await this.fetchAppConfig();
|
|
|
|
// Initialize components
|
|
this.initComponents();
|
|
|
|
// Bind event listeners
|
|
this.bindEvents();
|
|
|
|
// Load initial data
|
|
await this.loadConfigs();
|
|
|
|
// Fetch and display version
|
|
await this.fetchVersion();
|
|
|
|
// Determine initial section from URL hash or default to 'commands'
|
|
const initialSection = window.location.hash ? window.location.hash.substring(1) : 'commands';
|
|
this.currentSection = initialSection; // Set currentSection based on URL hash or default
|
|
this.showSection(initialSection);
|
|
|
|
// Listen for hash changes to update the section
|
|
window.addEventListener('hashchange', () => {
|
|
const sectionFromHash = window.location.hash ? window.location.hash.substring(1) : 'commands';
|
|
if (sectionFromHash !== this.currentSection) {
|
|
this.showSection(sectionFromHash);
|
|
}
|
|
});
|
|
|
|
console.log('qBit Manage Web UI initialized successfully');
|
|
|
|
// Restore sidebar state from localStorage
|
|
if (localStorage.getItem('sidebar-collapsed') === 'true') {
|
|
this.toggleSidebar(true); // Call with force=true to avoid animation on load
|
|
}
|
|
|
|
this.updateSidebarToggleIcon();
|
|
} catch (error) {
|
|
console.error('Failed to initialize application:', error);
|
|
showToast('Failed to initialize application', 'error');
|
|
}
|
|
}
|
|
|
|
async fetchAppConfig() {
|
|
try {
|
|
// Construct the API URL based on current location
|
|
const currentPath = window.location.pathname;
|
|
let basePath = currentPath.endsWith('/') ? currentPath.slice(0, -1) : currentPath;
|
|
if (basePath === '') basePath = '';
|
|
|
|
const configUrl = basePath + '/api/get_base_url';
|
|
|
|
const response = await fetch(configUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
const config = await response.json();
|
|
|
|
// Update the API instance with the base URL
|
|
if (config.baseUrl) {
|
|
const baseUrl = config.baseUrl.startsWith('/') ? config.baseUrl : '/' + config.baseUrl;
|
|
this.api.setBaseUrl(baseUrl);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch app configuration:', error);
|
|
// Continue with default configuration
|
|
}
|
|
}
|
|
|
|
async fetchVersion() {
|
|
try {
|
|
const response = await this.api.getVersion();
|
|
const versionText = response.version || 'Unknown';
|
|
document.getElementById('version-text').textContent = `qBit Manage v${versionText}`;
|
|
} catch (error) {
|
|
console.error('Failed to fetch version from API:', error);
|
|
document.getElementById('version-text').textContent = 'qBit Manage vUnknown';
|
|
}
|
|
}
|
|
|
|
|
|
initComponents() {
|
|
// Initialize form component
|
|
this.configForm = new ConfigForm({
|
|
container: get('section-content'),
|
|
onDataChange: (data) => this.handleConfigChange(data),
|
|
onValidationChange: (validation) => this.handleValidationChange(validation)
|
|
});
|
|
|
|
// Initialize command panel
|
|
this.commandPanel = new CommandPanel({
|
|
container: query('.command-panel'),
|
|
drawerContainer: get('command-panel-drawer'),
|
|
onCommandExecute: (commands, options) => this.executeCommands(commands, options)
|
|
});
|
|
|
|
// Initialize log viewer
|
|
this.logViewer = new LogViewer({
|
|
container: get('logs-section') // Render into the new logs section
|
|
});
|
|
// Initialize the LogViewer component
|
|
this.logViewer.init();
|
|
}
|
|
|
|
bindEvents() {
|
|
this._bindHeaderEvents();
|
|
this._bindNavigationEvents();
|
|
this._bindFooterEvents();
|
|
this._bindWindowEvents();
|
|
|
|
// Listen for form dirty events
|
|
document.addEventListener('form-dirty', (e) => {
|
|
this.updateSectionDirtyIndicator(e.detail.section, true);
|
|
});
|
|
|
|
// Listen for form reset events
|
|
document.addEventListener('form-reset', (e) => {
|
|
this.updateSectionDirtyIndicator(e.detail.section, false);
|
|
});
|
|
}
|
|
|
|
_bindHeaderEvents() {
|
|
// Config selector
|
|
const configSelect = get('config-select');
|
|
configSelect.addEventListener('change', (e) => {
|
|
this.loadConfig(e.target.value);
|
|
localStorage.setItem('qbm-last-selected-config', e.target.value);
|
|
});
|
|
|
|
// New config button
|
|
const newConfigBtn = get('new-config-btn');
|
|
if (newConfigBtn) {
|
|
newConfigBtn.addEventListener('click', () => {
|
|
this.showNewConfigModal();
|
|
});
|
|
}
|
|
|
|
// Save config button
|
|
get('save-config-btn').addEventListener('click', () => {
|
|
this.saveConfig();
|
|
});
|
|
|
|
// Validate config button
|
|
get('validate-config-btn').addEventListener('click', () => {
|
|
this.validateConfig();
|
|
});
|
|
|
|
// Backup config button
|
|
get('backup-config-btn').addEventListener('click', () => {
|
|
this.backupConfig();
|
|
});
|
|
|
|
// Theme toggle is handled by ThemeManager
|
|
|
|
// Undo button
|
|
const undoBtn = get('undo-btn');
|
|
if (undoBtn) {
|
|
undoBtn.addEventListener('click', () => {
|
|
this.undoConfig();
|
|
});
|
|
}
|
|
|
|
// Redo button
|
|
const redoBtn = get('redo-btn');
|
|
if (redoBtn) {
|
|
redoBtn.addEventListener('click', () => {
|
|
this.redoConfig();
|
|
});
|
|
}
|
|
|
|
// Sidebar toggle button
|
|
const sidebarToggle = get('sidebar-toggle');
|
|
if (sidebarToggle) {
|
|
sidebarToggle.addEventListener('click', () => {
|
|
if (window.innerWidth <= 768) {
|
|
this.toggleMobileMenu();
|
|
} else {
|
|
this.toggleSidebar();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
_bindNavigationEvents() {
|
|
// Navigation menu
|
|
get('nav-menu').addEventListener('click', (e) => {
|
|
const navLink = e.target.closest('.nav-link');
|
|
if (navLink) {
|
|
e.preventDefault();
|
|
const section = navLink.getAttribute('href').substring(1);
|
|
this.showSection(section);
|
|
|
|
// Close mobile menu when navigation item is clicked
|
|
this.closeMobileMenu();
|
|
}
|
|
});
|
|
}
|
|
|
|
_bindFooterEvents() {
|
|
// YAML preview toggle
|
|
get('yaml-preview-btn').addEventListener('click', () => {
|
|
this.toggleYamlPreview();
|
|
});
|
|
|
|
// Close YAML preview
|
|
get('close-preview-btn').addEventListener('click', () => {
|
|
this.hideYamlPreview();
|
|
});
|
|
|
|
// Help button
|
|
get('help-btn').addEventListener('click', () => {
|
|
this.showHelpModal();
|
|
});
|
|
|
|
|
|
}
|
|
|
|
_bindWindowEvents() {
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
this.handleKeyboardShortcuts(e);
|
|
});
|
|
|
|
// Window events
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (this.isDirty) {
|
|
e.preventDefault();
|
|
e.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
|
|
}
|
|
});
|
|
|
|
|
|
// Mobile sidebar overlay click to close
|
|
const sidebarOverlay = get('sidebar-overlay');
|
|
if (sidebarOverlay) {
|
|
sidebarOverlay.addEventListener('click', () => {
|
|
this.closeMobileMenu();
|
|
});
|
|
}
|
|
|
|
// Listen for history state changes
|
|
document.addEventListener('history-state-change', (e) => {
|
|
if (e.detail.configName === this.currentConfig) {
|
|
this.configData = e.detail.data;
|
|
this.showSection(this.currentSection);
|
|
this.updateHistoryButtons();
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', debounce(() => {
|
|
const sidebar = query('.sidebar');
|
|
if (sidebar) {
|
|
sidebar.style.transition = '';
|
|
}
|
|
this.updateSidebarToggleIcon();
|
|
}, 100));
|
|
}
|
|
|
|
async loadConfigs() {
|
|
try {
|
|
const response = await this.api.listConfigs();
|
|
const configSelect = get('config-select');
|
|
|
|
// Clear existing options
|
|
configSelect.innerHTML = '';
|
|
|
|
// Check if response is valid and has configs
|
|
if (!response || !response.configs) {
|
|
console.error('Invalid response from listConfigs:', response);
|
|
configSelect.innerHTML = '<option value="">Error loading configurations</option>';
|
|
return;
|
|
}
|
|
|
|
if (response.configs.length === 0) {
|
|
configSelect.innerHTML = '<option value="">No configurations found</option>';
|
|
return;
|
|
}
|
|
|
|
// Add config options
|
|
response.configs.forEach(config => {
|
|
const option = document.createElement('option');
|
|
option.value = config;
|
|
option.textContent = config;
|
|
if (config === response.default_config) {
|
|
option.selected = true;
|
|
}
|
|
configSelect.appendChild(option);
|
|
});
|
|
|
|
// Attempt to load the last selected config from localStorage
|
|
const lastSelectedConfig = localStorage.getItem('qbm-last-selected-config');
|
|
let configToLoad = response.default_config;
|
|
|
|
if (lastSelectedConfig && response.configs.includes(lastSelectedConfig)) {
|
|
configToLoad = lastSelectedConfig;
|
|
}
|
|
|
|
// Set the selected option in the dropdown
|
|
configSelect.value = configToLoad;
|
|
|
|
// Load the determined config
|
|
if (configToLoad) {
|
|
await this.loadConfig(configToLoad);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load configs:', error);
|
|
showToast('Failed to load configurations', 'error');
|
|
}
|
|
}
|
|
|
|
async loadConfig(filename) {
|
|
if (!filename) return;
|
|
|
|
try {
|
|
showLoading(get('section-content'));
|
|
const response = await this.api.getConfig(filename);
|
|
|
|
this.currentConfig = filename;
|
|
this.configData = response.data || {};
|
|
this.initialConfigData = JSON.parse(JSON.stringify(this.configData)); // Deep copy for dirty checking
|
|
this.isDirty = false;
|
|
|
|
// Initialize history manager for this config with initial data
|
|
await this.historyManager.initializeFromBackups(filename, this.configData);
|
|
|
|
// Update UI
|
|
this.updateSaveButton();
|
|
this.updateProgressIndicator();
|
|
this.clearAllDirtyIndicators();
|
|
this.updateHistoryButtons();
|
|
|
|
// Load current section
|
|
const sectionData = this.configForm.schemas[this.currentSection]?.type === 'multi-root-object'
|
|
? this.configData
|
|
: this.configData[this.currentSection] || {};
|
|
await this.configForm.loadSection(this.currentSection, sectionData);
|
|
|
|
hideLoading();
|
|
showToast(`Configuration "${filename}" loaded successfully`, 'success');
|
|
} catch (error) {
|
|
console.error('Failed to load config:', error);
|
|
hideLoading();
|
|
showToast(`Failed to load configuration "${filename}"`, 'error');
|
|
}
|
|
}
|
|
|
|
async saveConfig() {
|
|
if (!this.currentConfig) {
|
|
showToast('No configuration selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const processedData = this.configForm._postprocessDataForSave(this.currentSection, this.configForm.currentData);
|
|
|
|
const isMultiRoot = this.configForm.schemas[this.currentSection]?.type === 'multi-root-object';
|
|
const dataToSave = isMultiRoot ? processedData : { [this.currentSection]: processedData };
|
|
|
|
const fullConfigData = { ...this.configData, ...dataToSave };
|
|
|
|
// Preprocess nohardlinks before saving to fix null/empty values
|
|
if (fullConfigData.nohardlinks && typeof fullConfigData.nohardlinks === 'object' && fullConfigData.nohardlinks !== null) {
|
|
Object.keys(fullConfigData.nohardlinks).forEach(key => {
|
|
const value = fullConfigData.nohardlinks[key];
|
|
if (value === null || (typeof value === 'object' && Object.keys(value).length === 0)) {
|
|
fullConfigData.nohardlinks[key] = {
|
|
ignore_root_dir: true,
|
|
exclude_tags: []
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// Remove UI-only fields that should never be saved
|
|
delete fullConfigData.apply_to_all_value;
|
|
|
|
// Clean up empty values before saving
|
|
const cleanedConfigData = this.configForm.cleanupEmptyValues(fullConfigData);
|
|
|
|
// Create history checkpoint with the NEW state after saving
|
|
await this.historyManager.createCheckpoint(
|
|
this.currentConfig,
|
|
cleanedConfigData, // Use the new state after changes
|
|
`Updated ${this.currentSection} section`,
|
|
true // Mark as save point
|
|
);
|
|
|
|
showLoading(get('section-content'));
|
|
await this.api.updateConfig(this.currentConfig, { data: cleanedConfigData || {} });
|
|
|
|
// Reload the configuration from the server to get the actual saved data
|
|
// This ensures we have the correct data that was actually written to the file
|
|
const reloadedConfig = await this.api.getConfig(this.currentConfig);
|
|
this.configData = reloadedConfig.data || {};
|
|
this.initialConfigData = JSON.parse(JSON.stringify(this.configData)); // Update initial data after save
|
|
this.isDirty = false;
|
|
this.updateSaveButton();
|
|
this.clearAllDirtyIndicators();
|
|
|
|
// Re-render the current section to hide the loading spinner and show updated data
|
|
const sectionDataForReload = isMultiRoot ? this.configData : this.configData[this.currentSection] || {};
|
|
await this.configForm.loadSection(this.currentSection, sectionDataForReload);
|
|
|
|
hideLoading();
|
|
showToast(`Configuration "${this.currentConfig}" saved successfully`, 'success');
|
|
} catch (error) {
|
|
console.error('Failed to save config:', error);
|
|
hideLoading();
|
|
showToast(`Failed to save configuration "${this.currentConfig}"`, 'error');
|
|
}
|
|
}
|
|
|
|
async undoConfig() {
|
|
if (!this.currentConfig) {
|
|
showToast('No configuration selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!this.historyManager.canUndo(this.currentConfig)) {
|
|
showToast('Nothing to undo', 'info');
|
|
return;
|
|
}
|
|
|
|
showLoading(get('section-content'));
|
|
const result = await this.historyManager.undo(this.currentConfig);
|
|
|
|
if (result) {
|
|
// Update the configuration data and save to file without creating a backup
|
|
this.configData = result.data || {};
|
|
|
|
// Clean up empty values before saving
|
|
const cleanedConfigData = this.configForm.cleanupEmptyValues(this.configData);
|
|
|
|
// Save the undone state to the config file (overwrite without backup)
|
|
await this.api.updateConfig(this.currentConfig, { data: cleanedConfigData || {} });
|
|
|
|
this.initialConfigData = JSON.parse(JSON.stringify(this.configData));
|
|
this.isDirty = false;
|
|
this.updateSaveButton();
|
|
this.clearAllDirtyIndicators();
|
|
|
|
// Force full form reload with new data
|
|
const isMultiRoot = this.configForm.schemas[this.currentSection]?.type === 'multi-root-object';
|
|
const sectionData = isMultiRoot ? this.configData : this.configData[this.currentSection] || {};
|
|
await this.configForm.loadSection(this.currentSection, sectionData);
|
|
|
|
hideLoading();
|
|
showToast(`Undone: ${result.description}`, 'undo');
|
|
} else {
|
|
hideLoading();
|
|
showToast(`Cannot undo: ${result.error}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to undo config:', error);
|
|
hideLoading();
|
|
showToast('Failed to undo configuration changes', 'error');
|
|
}
|
|
this.updateHistoryButtons();
|
|
}
|
|
|
|
async redoConfig() {
|
|
if (!this.currentConfig) {
|
|
showToast('No configuration selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!this.historyManager.canRedo(this.currentConfig)) {
|
|
showToast('Nothing to redo', 'info');
|
|
return;
|
|
}
|
|
|
|
showLoading(get('section-content'));
|
|
const result = await this.historyManager.redo(this.currentConfig);
|
|
|
|
if (result) {
|
|
// Update the configuration data and save to file without creating a backup
|
|
this.configData = result.data || {};
|
|
|
|
// Clean up empty values before saving
|
|
const cleanedConfigData = this.configForm.cleanupEmptyValues(this.configData);
|
|
|
|
// Save the redone state to the config file (overwrite without backup)
|
|
await this.api.updateConfig(this.currentConfig, { data: cleanedConfigData || {} });
|
|
|
|
this.initialConfigData = JSON.parse(JSON.stringify(this.configData));
|
|
this.isDirty = false;
|
|
this.updateSaveButton();
|
|
this.clearAllDirtyIndicators();
|
|
|
|
// Force full form reload with new data
|
|
const isMultiRoot = this.configForm.schemas[this.currentSection]?.type === 'multi-root-object';
|
|
const sectionData = isMultiRoot ? this.configData : this.configData[this.currentSection] || {};
|
|
await this.configForm.loadSection(this.currentSection, sectionData);
|
|
|
|
hideLoading();
|
|
showToast(`Redone: ${result.description}`, 'redo');
|
|
} else {
|
|
hideLoading();
|
|
showToast('Nothing to redo', 'info');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to redo config:', error);
|
|
hideLoading();
|
|
showToast('Failed to redo configuration changes', 'error');
|
|
}
|
|
this.updateHistoryButtons();
|
|
}
|
|
|
|
updateHistoryButtons() {
|
|
const undoBtn = get('undo-btn');
|
|
const redoBtn = get('redo-btn');
|
|
|
|
if (!this.currentConfig) {
|
|
if (undoBtn) undoBtn.disabled = true;
|
|
if (redoBtn) redoBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
const canUndo = this.historyManager.canUndo(this.currentConfig);
|
|
const canRedo = this.historyManager.canRedo(this.currentConfig);
|
|
|
|
if (undoBtn) undoBtn.disabled = !canUndo;
|
|
if (redoBtn) redoBtn.disabled = !canRedo;
|
|
}
|
|
|
|
async validateConfig() {
|
|
if (!this.currentConfig) {
|
|
showToast('No configuration selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showLoading(get('section-content'));
|
|
const response = await this.api.validateConfig(this.currentConfig, { data: this.configData });
|
|
|
|
hideLoading();
|
|
|
|
if (response.valid) {
|
|
showToast('Configuration is valid', 'success');
|
|
} else {
|
|
// Pass the errors array directly instead of converting to string
|
|
this.showValidationModal('Configuration Validation Failed', response.errors, response.warnings);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to validate config:', error);
|
|
hideLoading();
|
|
showToast('Failed to validate configuration', 'error');
|
|
}
|
|
}
|
|
|
|
async backupConfig() {
|
|
if (!this.currentConfig) {
|
|
showToast('No configuration selected', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showLoading(get('section-content'));
|
|
const response = await this.api.backupConfig(this.currentConfig);
|
|
|
|
// Create a history checkpoint for the manual backup
|
|
this.historyManager.createCheckpoint(
|
|
this.currentConfig,
|
|
this.configData,
|
|
`Manual Backup: ${response.backup_file}`,
|
|
true // isSavePoint
|
|
);
|
|
|
|
hideLoading();
|
|
showToast(`Manual backup created: ${response.backup_file}`, 'success');
|
|
} catch (error) {
|
|
console.error('Failed to create backup:', error);
|
|
hideLoading();
|
|
showToast('Failed to create configuration backup', 'error');
|
|
}
|
|
}
|
|
|
|
showSection(sectionName) {
|
|
// Update URL hash
|
|
if (window.location.hash.substring(1) !== sectionName) {
|
|
window.location.hash = sectionName;
|
|
}
|
|
|
|
// Update navigation
|
|
queryAll('.nav-link').forEach(link => {
|
|
link.classList.remove('active');
|
|
});
|
|
|
|
const activeLink = query(`[href="#${sectionName}"]`);
|
|
if (activeLink) {
|
|
activeLink.classList.add('active');
|
|
}
|
|
|
|
// Section title removed - no longer needed since content header was removed
|
|
|
|
// Toggle visibility of content sections
|
|
const mainContentSection = get('section-content');
|
|
const logsSection = get('logs-section');
|
|
|
|
if (sectionName === 'logs') {
|
|
if (mainContentSection) hide(mainContentSection);
|
|
this.logViewer.show(); // Use the LogViewer's show method
|
|
} else {
|
|
if (mainContentSection) show(mainContentSection);
|
|
this.logViewer.hide(); // Use the LogViewer's hide method
|
|
// Load current section for config forms
|
|
if (this.configData) {
|
|
const sectionData = this.configForm.schemas[sectionName]?.type === 'multi-root-object'
|
|
? this.configData
|
|
: this.configData[sectionName] || {};
|
|
this.configForm.loadSection(sectionName, sectionData);
|
|
}
|
|
}
|
|
|
|
this.currentSection = sectionName;
|
|
}
|
|
|
|
handleConfigChange(sectionData) {
|
|
|
|
const isMultiRoot = this.configForm.schemas[this.currentSection]?.type === 'multi-root-object';
|
|
if (isMultiRoot) {
|
|
Object.assign(this.configData, sectionData);
|
|
} else {
|
|
const isMultiRoot = this.configForm.schemas[this.currentSection]?.type === 'multi-root-object';
|
|
if (isMultiRoot) {
|
|
Object.assign(this.configData, sectionData);
|
|
} else {
|
|
this.configData[this.currentSection] = sectionData;
|
|
}
|
|
}
|
|
this.isDirty = JSON.stringify(this.configData) !== JSON.stringify(this.initialConfigData);
|
|
|
|
this.updateSaveButton();
|
|
this.updateProgressIndicator();
|
|
}
|
|
|
|
handleValidationChange(validation) {
|
|
this.validationState[this.currentSection] = validation;
|
|
this.updateValidationIndicators();
|
|
}
|
|
|
|
updateSaveButton() {
|
|
const saveBtn = get('save-config-btn');
|
|
saveBtn.disabled = !this.isDirty || !this.currentConfig;
|
|
|
|
if (this.isDirty) {
|
|
saveBtn.classList.add('btn-warning');
|
|
saveBtn.classList.remove('btn-primary');
|
|
} else {
|
|
saveBtn.classList.add('btn-primary');
|
|
saveBtn.classList.remove('btn-warning');
|
|
}
|
|
}
|
|
|
|
updateProgressIndicator() {
|
|
// Progress indicator removed from UI - function kept for compatibility
|
|
// but no longer updates any visual elements
|
|
}
|
|
|
|
updateValidationIndicators() {
|
|
Object.keys(this.validationState).forEach(section => {
|
|
const navItem = query(`[data-section="${section}"]`);
|
|
if (navItem) {
|
|
const indicator = navItem.querySelector('.validation-indicator');
|
|
const validation = this.validationState[section];
|
|
|
|
indicator.classList.remove('valid', 'invalid', 'warning');
|
|
|
|
if (validation.errors && validation.errors.length > 0) {
|
|
indicator.classList.add('invalid');
|
|
} else if (validation.warnings && validation.warnings.length > 0) {
|
|
indicator.classList.add('warning');
|
|
} else if (validation.valid) {
|
|
indicator.classList.add('valid');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
updateSectionDirtyIndicator(sectionName, isDirty) {
|
|
const navLink = query(`.nav-link[href="#${sectionName}"]`);
|
|
if (navLink) {
|
|
if (isDirty) {
|
|
navLink.classList.add('dirty');
|
|
} else {
|
|
navLink.classList.remove('dirty');
|
|
}
|
|
}
|
|
}
|
|
|
|
clearAllDirtyIndicators() {
|
|
queryAll('.nav-link.dirty').forEach(link => {
|
|
link.classList.remove('dirty');
|
|
});
|
|
}
|
|
|
|
|
|
toggleYamlPreview() {
|
|
const previewContainer = get('yaml-preview');
|
|
if (previewContainer.classList.contains('active')) {
|
|
this.hideYamlPreview();
|
|
} else {
|
|
this.showYamlPreview();
|
|
}
|
|
}
|
|
|
|
showYamlPreview() {
|
|
const previewContainer = get('yaml-preview');
|
|
const yamlContent = get('yaml-content');
|
|
const mainContent = query('.main-content');
|
|
const yamlString = this.generateYamlString(this.configData);
|
|
const yamlPreviewBtn = get('yaml-preview-btn');
|
|
|
|
yamlContent.textContent = yamlString;
|
|
previewContainer.classList.remove('hidden');
|
|
|
|
// Allow the display property to take effect before starting the transition
|
|
setTimeout(() => {
|
|
if (mainContent) mainContent.classList.add('yaml-preview-active');
|
|
previewContainer.classList.add('active');
|
|
yamlPreviewBtn.classList.add('active');
|
|
}, 10);
|
|
}
|
|
|
|
hideYamlPreview() {
|
|
const previewContainer = get('yaml-preview');
|
|
const mainContent = query('.main-content');
|
|
const yamlPreviewBtn = get('yaml-preview-btn');
|
|
|
|
if (mainContent) mainContent.classList.remove('yaml-preview-active');
|
|
previewContainer.classList.remove('active');
|
|
yamlPreviewBtn.classList.remove('active');
|
|
|
|
// Hide with delay to allow transition to complete
|
|
setTimeout(() => {
|
|
previewContainer.classList.add('hidden');
|
|
}, 300); // Should match the transition duration in CSS
|
|
}
|
|
|
|
toggleLogPanel() {
|
|
const logDrawer = get('command-panel-drawer');
|
|
logDrawer.classList.toggle('show');
|
|
}
|
|
|
|
hideLogPanel() {
|
|
const logDrawer = get('command-panel-drawer');
|
|
logDrawer.classList.remove('show');
|
|
}
|
|
|
|
|
|
toggleSidebar(force = false) {
|
|
const sidebar = query('.sidebar');
|
|
const isCollapsed = sidebar.classList.toggle('collapsed');
|
|
localStorage.setItem('sidebar-collapsed', isCollapsed);
|
|
|
|
const content = query('.content');
|
|
if (isCollapsed) {
|
|
content.style.marginLeft = 'var(--sidebar-width-collapsed)';
|
|
} else {
|
|
content.style.marginLeft = 'var(--sidebar-width-expanded)';
|
|
}
|
|
|
|
if (!force) {
|
|
sidebar.style.transition = 'width var(--transition-normal)';
|
|
content.style.transition = 'margin-left var(--transition-normal)';
|
|
} else {
|
|
sidebar.style.transition = 'none';
|
|
content.style.transition = 'none';
|
|
}
|
|
|
|
this.updateSidebarToggleIcon();
|
|
|
|
// Dispatch a resize event to make sure any components that rely on container width are redrawn (e.g., charts)
|
|
window.dispatchEvent(new Event('resize'));
|
|
}
|
|
|
|
toggleMobileMenu() {
|
|
const sidebar = query('.sidebar');
|
|
sidebar.classList.toggle('show');
|
|
this.updateSidebarToggleIcon();
|
|
}
|
|
|
|
closeMobileMenu() {
|
|
const sidebar = query('.sidebar');
|
|
sidebar.classList.remove('show');
|
|
this.updateSidebarToggleIcon();
|
|
}
|
|
|
|
/**
|
|
* Converts a JSON object to a YAML-formatted string.
|
|
* @param {object} data - The JSON object to convert.
|
|
* @returns {string} The YAML-formatted string.
|
|
*/
|
|
generateYamlString(data) {
|
|
if (!data) return '';
|
|
|
|
const processValue = (value, indent = 0) => {
|
|
const indentStr = ' '.repeat(indent);
|
|
if (value === null) {
|
|
return 'null';
|
|
}
|
|
if (typeof value === 'object') {
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) return '[]'; // Represent empty array explicitly
|
|
return value.map(item => `\n${indentStr}- ${processValue(item, indent)}`).join('');
|
|
} else {
|
|
return Object.entries(value)
|
|
.map(([key, val]) => `\n${indentStr}${key}: ${processValue(val, indent + 1)}`)
|
|
.join('');
|
|
}
|
|
}
|
|
return value;
|
|
};
|
|
|
|
return Object.entries(data)
|
|
.map(([key, value]) => `${key}: ${processValue(value, 1)}`)
|
|
.join('\n');
|
|
}
|
|
|
|
updateSidebarToggleIcon() {
|
|
const sidebarToggle = get('sidebar-toggle');
|
|
if (sidebarToggle) {
|
|
const icon = sidebarToggle.querySelector('.material-icons');
|
|
const sidebar = query('.sidebar');
|
|
|
|
if (window.innerWidth <= 768) {
|
|
// Mobile: check if sidebar has 'show' class (open state)
|
|
if (sidebar.classList.contains('show')) {
|
|
icon.textContent = 'menu_open';
|
|
} else {
|
|
icon.textContent = 'menu';
|
|
}
|
|
} else {
|
|
// Desktop: check if sidebar has 'collapsed' class
|
|
if (sidebar.classList.contains('collapsed')) {
|
|
icon.textContent = 'menu';
|
|
} else {
|
|
icon.textContent = 'menu_open';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
handleKeyboardShortcuts(e) {
|
|
if (e.ctrlKey && e.key === 's') {
|
|
e.preventDefault();
|
|
this.saveConfig();
|
|
}
|
|
if (e.ctrlKey && e.key === 'r') {
|
|
e.preventDefault();
|
|
this.commandPanel.toggleRunCommandsModal();
|
|
}
|
|
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) { // Ctrl + Z (undo)
|
|
e.preventDefault();
|
|
this.undoConfig();
|
|
}
|
|
if (e.ctrlKey && e.key === 'y') { // Ctrl + Y (redo)
|
|
e.preventDefault();
|
|
this.redoConfig();
|
|
}
|
|
if (e.ctrlKey && e.key === '/' && !e.shiftKey) { // Ctrl + /
|
|
e.preventDefault();
|
|
this.toggleHelpModal();
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'p') { // Ctrl+P or Cmd+P for YAML Preview
|
|
e.preventDefault();
|
|
this.toggleYamlPreview();
|
|
}
|
|
if (e.key === 'Escape') {
|
|
// Attempt to close the run commands modal if it's open
|
|
if (this.commandPanel && this.commandPanel.runCommandsModal && this.commandPanel.runCommandsModal.parentNode) {
|
|
this.commandPanel.hideRunCommandsModal();
|
|
return; // Exit early since we handled the modal
|
|
}
|
|
|
|
// Attempt to close the share limits edit modal if it's open
|
|
const shareLimitsModal = document.querySelector('.share-limit-modal');
|
|
if (shareLimitsModal && shareLimitsModal.parentNode) {
|
|
shareLimitsModal.parentNode.removeChild(shareLimitsModal);
|
|
// Also clear the helpModal reference if it was the share limits modal (unlikely but safe)
|
|
if (this.helpModal === shareLimitsModal) {
|
|
this.helpModal = null;
|
|
}
|
|
// Do NOT return here, allow other escape actions to proceed
|
|
}
|
|
|
|
// Close other modals/panels
|
|
this.hideYamlPreview();
|
|
this.hideLogPanel();
|
|
// Check if the global modal is open and close it
|
|
const globalModalOverlay = document.getElementById('modal-overlay');
|
|
if (this.helpModal && this.helpModal.parentNode) {
|
|
// If the help modal is open, close it
|
|
this.hideHelpModal();
|
|
}
|
|
if (globalModalOverlay && globalModalOverlay.style.display !== 'none') {
|
|
hideModal(); // Use the utility function for the global modal
|
|
this.hideHelpModal();
|
|
}
|
|
}
|
|
}
|
|
|
|
async executeCommands(commands, options) {
|
|
try {
|
|
this.showSection('logs');
|
|
this.logViewer.clearLogs();
|
|
this.logViewer.log('info', `Executing commands: ${commands.join(', ')} with options: ${JSON.stringify(options)}`);
|
|
|
|
const response = await this.api.runCommand({
|
|
commands: commands,
|
|
config_file: this.currentConfig,
|
|
hashes: options.hashes || [],
|
|
dry_run: options.dryRun, // Map dryRun from options to dry_run for the API
|
|
skip_cleanup: options.skip_cleanup,
|
|
skip_qb_version_check: options.skip_qb_version_check,
|
|
log_level: options.log_level
|
|
});
|
|
|
|
this.logViewer.log('info', 'Commands executed successfully.');
|
|
|
|
// Display the API response in the log viewer
|
|
if (response) {
|
|
this.logViewer.log('info', `Response: ${JSON.stringify(response, null, 2)}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Command execution failed:', error);
|
|
this.logViewer.log('error', `Command execution failed: ${error.message}`);
|
|
|
|
// Refresh logs even on error to show any server-side logs
|
|
await this.logViewer.loadRecentLogs();
|
|
}
|
|
}
|
|
|
|
async showNewConfigModal() {
|
|
const modalContent = `
|
|
<div class="form-group">
|
|
<label for="new-config-name" class="form-label">New Configuration Name</label>
|
|
<input type="text" id="new-config-name" class="form-input" placeholder="e.g., config_new.yml">
|
|
</div>
|
|
`;
|
|
|
|
const confirmed = await showModal('Create New Configuration', modalContent, {
|
|
confirmText: 'Create',
|
|
cancelText: 'Cancel',
|
|
showCancel: true
|
|
});
|
|
|
|
if (confirmed) {
|
|
const newConfigName = get('new-config-name').value;
|
|
if (newConfigName) {
|
|
try {
|
|
// Ensure name ends with .yml
|
|
const finalName = newConfigName.endsWith('.yml') ? newConfigName : `${newConfigName}.yml`;
|
|
|
|
// Create an empty config file
|
|
await this.api.createConfig(finalName, { data: {} });
|
|
|
|
showToast(`Configuration "${finalName}" created successfully`, 'success');
|
|
|
|
// Reload the config list to show the new config
|
|
await this.loadConfigs();
|
|
|
|
// Select the new config in the dropdown
|
|
const configSelect = get('config-select');
|
|
configSelect.value = finalName;
|
|
|
|
// Manually trigger the change event to load the new config
|
|
configSelect.dispatchEvent(new Event('change'));
|
|
|
|
} catch (error) {
|
|
console.error('Failed to create new config:', error);
|
|
showToast('Failed to create new configuration', 'error');
|
|
}
|
|
} else {
|
|
showToast('Please enter a name for the new configuration.', 'warning');
|
|
}
|
|
}
|
|
}
|
|
|
|
showValidationModal(title, errors, warnings = []) {
|
|
const errorList = errors && errors.length > 0 ? `<h3 class="validation-subheader">Errors:</h3><ul class="validation-list">${errors.map(e => `<li>${e}</li>`).join('')}</ul>` : '';
|
|
const warningList = warnings.length > 0 ? `<h3 class="validation-subheader">Warnings:</h3><ul class="validation-list">${warnings.map(w => `<li>${w}</li>`).join('')}</ul>` : '';
|
|
const modalContent = `<div class="validation-results">${errorList}${warningList}</div>`;
|
|
|
|
showModal(title, modalContent, {
|
|
confirmText: 'Close',
|
|
showCancel: false
|
|
});
|
|
}
|
|
|
|
hideHelpModal() {
|
|
if (this.helpModal && this.helpModal.parentNode) {
|
|
this.helpModal.parentNode.removeChild(this.helpModal);
|
|
this.helpModal = null;
|
|
}
|
|
}
|
|
|
|
toggleHelpModal() {
|
|
if (this.helpModal && this.helpModal.parentNode) {
|
|
// If modal exists and is in DOM, hide it
|
|
this.hideHelpModal();
|
|
} else {
|
|
// Otherwise, show it
|
|
this.showHelpModal();
|
|
}
|
|
}
|
|
|
|
showHelpModal() {
|
|
// Ensure any previous modal is removed before creating a new one
|
|
if (this.helpModal && this.helpModal.parentNode) {
|
|
this.helpModal.parentNode.removeChild(this.helpModal);
|
|
}
|
|
this.helpModal = null; // Reset to null before creating a new one
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal-overlay'; // Use the same class as other modals
|
|
modal.innerHTML = `
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>Help & Shortcuts</h3>
|
|
<button type="button" class="modal-close-btn btn btn-icon btn-close-icon">
|
|
<svg class="icon" viewBox="0 0 24 24">
|
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="modal-content">
|
|
<h3 class="help-title">Keyboard Shortcuts</h3>
|
|
<ul class="key-value-list">
|
|
<li class="key-value-item">
|
|
<span class="key-name">Ctrl + S</span>
|
|
<span class="value-name">Save current configuration</span>
|
|
</li>
|
|
<li class="key-value-item">
|
|
<span class="key-name">Ctrl + Z</span>
|
|
<span class="value-name">Undo last configuration change</span>
|
|
</li>
|
|
<li class="key-value-item">
|
|
<span class="key-name">Ctrl + Y</span>
|
|
<span class="value-name">Redo configuration change</span>
|
|
</li>
|
|
<li class="key-value-item">
|
|
<span class="key-name">Ctrl + R</span>
|
|
<span class="value-name">Toggle commands modal</span>
|
|
</li>
|
|
<li class="key-value-item">
|
|
<span class="key-name">Ctrl + /</span>
|
|
<span class="value-name">Toggle keyboard shortcuts help</span>
|
|
</li>
|
|
<li class="key-value-item">
|
|
<span class="key-name">Ctrl + P</span>
|
|
<span class="value-name">Toggle YAML preview</span>
|
|
</li>
|
|
<li class="key-value-item">
|
|
<span class="key-name">Esc</span>
|
|
<span class="value-name">Close modals, YAML preview, and log panel</span>
|
|
</li>
|
|
</ul>
|
|
<h3 class="help-title">Need More Help?</h3>
|
|
<p>For more detailed documentation and support, please visit the
|
|
<a href="https://github.com/StuffAnThings/qbit_manage/wiki" target="_blank" rel="noopener noreferrer">
|
|
Official GitHub Repository
|
|
</a>.
|
|
</p>
|
|
<h3 class="help-title">Developer Resources</h3>
|
|
<p>For API documentation and technical details:
|
|
<br>
|
|
<a href="/docs" target="_blank" rel="noopener noreferrer">
|
|
📚 Interactive API Documentation (Swagger UI)
|
|
</a>
|
|
<br>
|
|
<a href="/redoc" target="_blank" rel="noopener noreferrer">
|
|
📖 Alternative API Documentation (ReDoc)
|
|
</a>
|
|
</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary modal-cancel-btn">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
this.helpModal = modal; // Store reference to the modal
|
|
|
|
// Bind modal events
|
|
const closeModal = () => {
|
|
this.hideHelpModal();
|
|
};
|
|
|
|
modal.querySelector('.modal-close-btn').addEventListener('click', closeModal);
|
|
modal.querySelector('.modal-cancel-btn').addEventListener('click', closeModal);
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) closeModal();
|
|
});
|
|
}
|
|
|
|
showRunCommandsModal() {
|
|
if (!this.commandPanel.schema || this.commandPanel.schema.length === 0) {
|
|
showToast('No commands available to run.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const commandOptions = this.commandPanel.schema.map(cmd => {
|
|
return `
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" name="command" value="${cmd.name}" class="form-checkbox">
|
|
<span class="checkmark"></span>
|
|
${cmd.name}
|
|
<small class="command-description">${cmd.description}</small>
|
|
</label>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const dryRunOption = `
|
|
<div class="form-group">
|
|
<label class="checkbox-label">
|
|
<input type="checkbox" id="dry-run-checkbox" class="form-checkbox">
|
|
<span class="checkmark"></span>
|
|
Dry Run
|
|
<small class="command-description">Preview changes without applying them.</small>
|
|
</label>
|
|
</div>
|
|
`;
|
|
|
|
const modalContent = `
|
|
<div class="run-commands-modal">
|
|
<p>Select the commands you want to run.</p>
|
|
<div class="command-list">${commandOptions}</div>
|
|
<hr>
|
|
${dryRunOption}
|
|
</div>
|
|
`;
|
|
|
|
showModal('Run Commands', modalContent, [
|
|
{ label: 'Cancel', classes: 'btn-secondary', action: hideModal },
|
|
{
|
|
label: 'Run Selected',
|
|
classes: 'btn-primary',
|
|
action: async () => {
|
|
const selectedCommands = Array.from(queryAll('input[name="command"]:checked')).map(cb => cb.value);
|
|
const isDryRun = get('dry-run-checkbox').checked;
|
|
|
|
if (selectedCommands.length > 0) {
|
|
this.executeCommands(selectedCommands, { 'dry_run': isDryRun });
|
|
hideModal();
|
|
} else {
|
|
showToast('No commands selected.', 'warning');
|
|
}
|
|
}
|
|
}
|
|
]);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.app = new QbitManageApp();
|
|
});
|