/**
* 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 { SchedulerControl } from './components/SchedulerControl.js';
import { SecurityComponent } from './components/security.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.schedulerControl = null;
this.securityComponent = 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();
// Initialize scheduler after components
this.initSchedulerControl();
// Initialize security component
this.initSecurityComponent();
} 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';
const versionEl = document.getElementById('version-text');
if (versionEl) {
versionEl.textContent = `qBit Manage v${versionText}`;
if (response.update_available) {
const badge = document.createElement('a');
badge.className = 'badge badge-warning';
const latest = response.latest_version || 'latest';
badge.textContent = `Update available: ${latest}`;
badge.style.marginLeft = '0.5rem';
badge.style.cursor = 'pointer';
badge.style.textDecoration = 'underline';
badge.target = '_blank';
badge.rel = 'noopener noreferrer';
// Determine the correct URL based on branch
let updateUrl;
if (response.branch === 'develop') {
updateUrl = 'https://github.com/StuffAnThings/qbit_manage/actions/workflows/develop.yml';
} else {
// For stable releases, link to the release page
updateUrl = `https://github.com/StuffAnThings/qbit_manage/releases/tag/v${latest}`;
}
badge.href = updateUrl;
if (versionEl.parentElement) {
versionEl.parentElement.appendChild(badge);
}
const branch = response.branch ? ` (${response.branch})` : '';
showToast(`A new version is available${branch}: ${latest}`, 'info');
}
}
} catch (error) {
console.error('Failed to fetch version from API:', error);
const versionEl = document.getElementById('version-text');
if (versionEl) {
versionEl.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();
}
initSchedulerControl() {
const schedulerContainer = get('scheduler-control-container');
if (schedulerContainer) {
this.schedulerControl = new SchedulerControl({
container: schedulerContainer,
});
}
}
initSecurityComponent() {
const securityContainer = get('security-container');
if (securityContainer) {
this.securityComponent = new SecurityComponent('security-container', this.api);
this.securityComponent.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', () => {
if (this.currentSection === 'security') {
this.saveSecuritySettings();
} else {
this.saveConfig();
}
});
// Validate config button
get('validate-config-btn').addEventListener('click', () => {
this.validateConfig();
});
// Backup config button
get('backup-config-btn').addEventListener('click', () => {
this.backupConfig();
});
// 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 = '';
return;
}
if (response.configs.length === 0) {
configSelect.innerHTML = '';
return;
}
// Add config options (exclude schedule.yml as it's not a regular config file)
response.configs
.filter(config => config !== 'schedule.yml')
.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 (skip for schedule.yml as it's not a regular config file)
if (filename !== 'schedule.yml') {
// If current section is 'scheduler' (UI-only section), switch to 'commands' for config files
let sectionToLoad = this.currentSection;
if (this.currentSection === 'scheduler') {
sectionToLoad = 'commands';
this.currentSection = 'commands';
// Update the navigation to reflect the section change
this.showSection('commands');
}
const sectionData = this.configForm.schemas[sectionToLoad]?.type === 'multi-root-object'
? this.configData
: this.configData[sectionToLoad] || {};
await this.configForm.loadSection(sectionToLoad, sectionData);
} else {
// For schedule.yml, clear the form since it's not a regular config
this.configForm.container.innerHTML = '
Schedule configuration is managed through the Scheduler tab.
';
}
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 {
// For commands section, collect all form values since commands should override env vars
let dataToProcess;
if (this.currentSection === 'commands') {
dataToProcess = this.configForm.collectAllFormValues(this.currentSection);
} else {
dataToProcess = this.configForm.currentData;
}
const processedData = this.configForm._postprocessDataForSave(this.currentSection, dataToProcess);
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) {
// Check if config was modified during validation
if (response.config_modified) {
showToast('Configuration validated successfully! Default values have been added.', 'success');
// Reload the configuration data from the server to reflect changes
try {
const configResponse = await this.api.getConfig(this.currentConfig);
if (configResponse && configResponse.data) {
// Update the app's config data
this.configData = configResponse.data;
// Reload the current section to reflect changes
if (this.configForm && this.currentSection) {
await this.configForm.loadSection(this.currentSection, this.configData[this.currentSection] || {});
}
// Update YAML preview if it's open
if (this.yamlPreviewVisible) {
this.updateYamlPreview();
}
}
} catch (reloadError) {
console.error('Error reloading config after main validation:', reloadError);
showToast('Configuration validated but failed to reload updated data.', 'warning');
}
} else {
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');
const schedulerSection = get('scheduler-section');
const securitySection = get('security-section');
if (sectionName === 'logs') {
if (mainContentSection) hide(mainContentSection);
if (schedulerSection) hide(schedulerSection);
if (securitySection) hide(securitySection);
this.logViewer.show(); // Use the LogViewer's show method
} else if (sectionName === 'scheduler') {
if (mainContentSection) hide(mainContentSection);
if (logsSection) hide(logsSection);
if (securitySection) hide(securitySection);
this.logViewer.hide(); // Use the LogViewer's hide method
if (schedulerSection) show(schedulerSection);
// Scheduler should already be initialized during app startup
// No need to initialize again here
} else if (sectionName === 'security') {
if (mainContentSection) hide(mainContentSection);
if (logsSection) hide(logsSection);
if (schedulerSection) hide(schedulerSection);
if (securitySection) show(securitySection);
this.logViewer.hide(); // Use the LogViewer's hide method
// Security section is handled by SecurityComponent
// No need to load config data for security
} else {
if (mainContentSection) show(mainContentSection);
if (logsSection) hide(logsSection);
if (schedulerSection) hide(schedulerSection);
if (securitySection) hide(securitySection);
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 = `
`;
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 ? `
`;
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 = `