diff --git a/frontend/static/css/style.css b/frontend/static/css/style.css index 8739e6db..f9927f49 100644 --- a/frontend/static/css/style.css +++ b/frontend/static/css/style.css @@ -873,7 +873,6 @@ input:checked + .toggle-slider:before { cursor: pointer; display: inline-flex; align-items: center; - justify-content: center; gap: 4px; transition: background-color 0.2s ease; width: fit-content; @@ -1115,7 +1114,6 @@ input:checked + .toggle-slider:before { cursor: pointer; display: flex; align-items: center; - justify-content: center; gap: 10px; transition: background-color 0.3s; } @@ -1276,4 +1274,171 @@ input:checked + .toggle-slider:before { #reset_stateful_btn i { font-size: 13px; +} + +/* Apps Section */ +/* Use the existing log dropdown styles for app section. No custom CSS needed for the dropdown itself. */ + +/* App settings content styling */ +.settings-content { + margin-top: 20px; +} + +.app-apps-panel { + display: none; + width: 100%; +} + +.app-apps-panel.active { + display: block; +} + +/* Instance panel styling */ +.instance-panel { + background-color: var(--bg-secondary, #2c2c2c); + border-radius: 4px; + padding: 15px; + margin-bottom: 15px; + border: 1px solid var(--border-color, #3c3c3c); +} + +.instance-header { + display: flex; + align-items: center; + margin-bottom: 15px; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color, #3c3c3c); +} + +.instance-name { + flex: 1; + padding: 8px; + background-color: var(--bg-tertiary, #252525); + border: 1px solid var(--border-color, #3c3c3c); + border-radius: 4px; + color: var(--text-primary, white); + font-size: 14px; +} + +.form-field { + margin-bottom: 15px; +} + +.form-field label { + display: block; + margin-bottom: 5px; + font-weight: 400; + color: var(--text-primary, #f0f0f0); + font-size: 14px; +} + +.form-field input { + padding: 8px; + background-color: var(--bg-tertiary, #252525); + border: 1px solid var(--border-color, #3c3c3c); + border-radius: 4px; + color: var(--text-primary, white); + width: 100%; + max-width: 500px; + font-size: 14px; +} + +/* Button styling */ +.add-instance-btn { + background-color: var(--accent-color, #007bff); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 14px; + margin-top: 15px; +} + +.add-instance-btn:hover { + background-color: var(--accent-hover, #0069d9); +} + +.remove-instance-btn { + background-color: #dc3545; + color: white; + border: none; + width: 30px; + height: 30px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.remove-instance-btn:hover { + background-color: #c82333; +} + +.test-connection-btn { + background-color: #28a745; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 13px; + margin-top: 5px; +} + +.test-connection-btn:hover { + background-color: #218838; +} + +/* Match styling with existing settings UI */ +#appsSection .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid var(--border-color, #3c3c3c); + padding-bottom: 10px; +} + +#appsSection .settings-group { + margin-top: 20px; + margin-bottom: 30px; + background-color: var(--bg-secondary, #252525); + border-radius: 4px; + padding: 20px; +} + +#appsSection .settings-group-header { + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color, #363636); + font-size: 16px; + font-weight: 500; + color: var(--text-primary, #f0f0f0); +} + +.loading-panel { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + color: var(--text-primary, #f0f0f0); + gap: 10px; +} + +.error-panel { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + color: #dc3545; + gap: 10px; } \ No newline at end of file diff --git a/frontend/static/js/apps.js b/frontend/static/js/apps.js new file mode 100644 index 00000000..57146e2d --- /dev/null +++ b/frontend/static/js/apps.js @@ -0,0 +1,308 @@ +/** + * Huntarr - Apps Module + * Handles displaying and managing app settings for media server applications + */ + +const appsModule = { + // State + currentApp: 'sonarr', + isLoading: false, + settingsChanged: false, + originalSettings: {}, + + // DOM elements + elements: {}, + + // Initialize the apps module + init: function() { + this.cacheElements(); + this.setupEventListeners(); + + // Initial load if apps is active section + if (huntarrUI && huntarrUI.currentSection === 'apps') { + this.loadApps(); + } + }, + + // Cache DOM elements + cacheElements: function() { + this.elements = { + // Apps dropdown + appsOptions: document.querySelectorAll('#appsSection .log-option'), + currentAppsApp: document.getElementById('current-apps-app'), + appsDropdownBtn: document.querySelector('#appsSection .log-dropdown-btn'), + appsDropdownContent: document.querySelector('#appsSection .log-dropdown-content'), + + // Apps panels + appAppsPanels: document.querySelectorAll('.app-apps-panel'), + + // Controls + saveAppsButton: document.getElementById('saveAppsButton') + }; + }, + + // Set up event listeners + setupEventListeners: function() { + // App selection + if (this.elements.appsOptions) { + this.elements.appsOptions.forEach(option => { + option.addEventListener('click', e => this.handleAppsAppChange(e)); + }); + } + + // Dropdown toggle + if (this.elements.appsDropdownBtn) { + this.elements.appsDropdownBtn.addEventListener('click', () => { + this.elements.appsDropdownContent.classList.toggle('show'); + + // Close all other dropdowns + document.querySelectorAll('.log-dropdown-content.show').forEach(dropdown => { + if (dropdown !== this.elements.appsDropdownContent) { + dropdown.classList.remove('show'); + } + }); + }); + } + + // Close dropdown when clicking outside + document.addEventListener('click', e => { + if (!e.target.matches('#appsSection .log-dropdown-btn') && + !e.target.closest('#appsSection .log-dropdown-btn')) { + if (this.elements.appsDropdownContent && this.elements.appsDropdownContent.classList.contains('show')) { + this.elements.appsDropdownContent.classList.remove('show'); + } + } + }); + + // Save button + if (this.elements.saveAppsButton) { + this.elements.saveAppsButton.addEventListener('click', () => this.saveApps()); + } + }, + + // Load apps data when section becomes active + loadApps: function() { + console.log('[Apps] Loading apps data for ' + this.currentApp); + + // Disable save button until changes are made + if (this.elements.saveAppsButton) { + this.elements.saveAppsButton.disabled = true; + } + this.settingsChanged = false; + + // Get all settings to populate forms + fetch('/api/settings') + .then(response => response.json()) + .then(data => { + console.log('Loaded settings:', data); + + // Store original settings for comparison + this.originalSettings = data; + + // Ensure current app panel is visible + this.showAppPanel(this.currentApp); + + // Populate each app's settings form + this.populateAllAppPanels(data); + }) + .catch(error => { + console.error('Error loading settings:', error); + const appPanel = document.getElementById(this.currentApp + 'Apps'); + if (appPanel) { + appPanel.innerHTML = '
Failed to load app settings. Please try again.
'; + } + }); + }, + + // Populate all app panels with settings + populateAllAppPanels: function(data) { + // Clear existing panels + this.elements.appAppsPanels.forEach(panel => { + panel.innerHTML = ''; + }); + + // Populate each app panel + if (data.sonarr) this.populateAppPanel('sonarr', data.sonarr); + if (data.radarr) this.populateAppPanel('radarr', data.radarr); + if (data.lidarr) this.populateAppPanel('lidarr', data.lidarr); + if (data.readarr) this.populateAppPanel('readarr', data.readarr); + if (data.whisparr) this.populateAppPanel('whisparr', data.whisparr); + if (data.swaparr) this.populateAppPanel('swaparr', data.swaparr); + }, + + // Populate a specific app panel with settings + populateAppPanel: function(app, appSettings) { + const appPanel = document.getElementById(app + 'Apps'); + if (!appPanel) return; + + // Create settings container + const settingsContainer = document.createElement('div'); + settingsContainer.className = 'settings-group'; + + // Create settings form + const settingsForm = document.createElement('div'); + settingsForm.id = app + 'SettingsForm'; + settingsForm.className = 'settings-form'; + + // Add to container and panel + settingsContainer.appendChild(settingsForm); + appPanel.appendChild(settingsContainer); + + // Generate the form using SettingsForms module + if (typeof SettingsForms !== 'undefined') { + const formFunction = SettingsForms[`generate${app.charAt(0).toUpperCase()}${app.slice(1)}Form`]; + if (typeof formFunction === 'function') { + formFunction(settingsForm, appSettings); + + // Update duration displays for this app + if (typeof SettingsForms.updateDurationDisplay === 'function') { + SettingsForms.updateDurationDisplay(); + } + + // Add change listener to detect modifications + this.addFormChangeListeners(settingsForm); + } else { + console.warn(`Form generation function not found for ${app}`); + settingsForm.innerHTML = `
Settings for ${app.charAt(0).toUpperCase() + app.slice(1)} are not available.
`; + } + } else { + console.error('SettingsForms module not found'); + settingsForm.innerHTML = '
Unable to generate settings form. Please reload the page.
'; + } + }, + + // Add change event listeners to form elements + addFormChangeListeners: function(form) { + const inputs = form.querySelectorAll('input, select, textarea'); + inputs.forEach(input => { + input.addEventListener('change', () => this.markAppsAsChanged()); + // For text inputs, also listen for input event + if (input.type === 'text' || input.type === 'password' || input.type === 'number' || input.tagName.toLowerCase() === 'textarea') { + input.addEventListener('input', () => this.markAppsAsChanged()); + } + }); + }, + + // Show specific app panel and hide others + showAppPanel: function(app) { + // Hide all app panels + this.elements.appAppsPanels.forEach(panel => { + panel.classList.remove('active'); + panel.style.display = 'none'; + }); + + // Show the selected app's panel + const selectedPanel = document.getElementById(app + 'Apps'); + if (selectedPanel) { + selectedPanel.classList.add('active'); + selectedPanel.style.display = 'block'; + } + }, + + // Handle app selection changes + handleAppsAppChange: function(e) { + e.preventDefault(); + + const selectedApp = e.target.getAttribute('data-app'); + if (!selectedApp || selectedApp === this.currentApp) return; + + // Check if there are unsaved changes + if (this.settingsChanged) { + const confirmSwitch = confirm('You have unsaved changes. Do you want to continue without saving?'); + if (!confirmSwitch) { + return; + } + } + + // Update UI + this.elements.appsOptions.forEach(option => { + option.classList.remove('active'); + }); + e.target.classList.add('active'); + + // Update the current app text with proper capitalization + let displayName = selectedApp.charAt(0).toUpperCase() + selectedApp.slice(1); + this.elements.currentAppsApp.textContent = displayName; + + // Close the dropdown + this.elements.appsDropdownContent.classList.remove('show'); + + // Show the selected app's panel + this.showAppPanel(selectedApp); + + this.currentApp = selectedApp; + console.log(`[Apps] Switched app to: ${this.currentApp}`); + + // Reset changed state + this.settingsChanged = false; + this.elements.saveAppsButton.disabled = true; + }, + + // Mark apps as changed + markAppsAsChanged: function() { + this.settingsChanged = true; + if (this.elements.saveAppsButton) { + this.elements.saveAppsButton.disabled = false; + } + }, + + // Save apps settings + saveApps: function() { + console.log('[Apps] Saving app settings for ' + this.currentApp); + + // Gather settings from all app forms + const allSettings = {}; + const apps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'swaparr']; + + // Loop through each app and collect settings + apps.forEach(app => { + const appPanel = document.getElementById(app + 'Apps'); + if (!appPanel) return; + + const appForm = appPanel.querySelector('.settings-form'); + if (!appForm) return; + + // Get settings using SettingsForms + if (typeof SettingsForms !== 'undefined' && typeof SettingsForms.getFormSettings === 'function') { + const appSettings = SettingsForms.getFormSettings(appForm); + if (appSettings) { + allSettings[app] = appSettings; + } + } + }); + + // Send settings update request + fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ [this.currentApp]: allSettings[this.currentApp] }) + }) + .then(response => response.json()) + .then(data => { + console.log('Settings saved:', data); + + // Disable save button + this.settingsChanged = false; + if (this.elements.saveAppsButton) { + this.elements.saveAppsButton.disabled = true; + } + + // Show success message + alert('Settings saved successfully!'); + + // Update original settings + this.originalSettings = data; + }) + .catch(error => { + console.error('Error saving settings:', error); + alert('Error saving settings. Please try again.'); + }); + } +}; + +// Initialize when document is ready +document.addEventListener('DOMContentLoaded', () => { + appsModule.init(); +}); diff --git a/frontend/static/js/new-main.js b/frontend/static/js/new-main.js index 01bec0fa..cba0f6c6 100644 --- a/frontend/static/js/new-main.js +++ b/frontend/static/js/new-main.js @@ -419,6 +419,18 @@ let huntarrUI = { this.currentSection = 'history'; // Disconnect logs if switching away from logs this.disconnectAllEventSources(); + } else if (section === 'apps' && document.getElementById('appsSection')) { + document.getElementById('appsSection').classList.add('active'); + if (document.getElementById('appsNav')) document.getElementById('appsNav').classList.add('active'); + newTitle = 'Apps'; + this.currentSection = 'apps'; + // Disconnect logs if switching away from logs + this.disconnectAllEventSources(); + + // Load apps if the apps module exists + if (typeof appsModule !== 'undefined') { + appsModule.loadApps(); + } } else if (section === 'settings' && this.elements.settingsSection) { this.elements.settingsSection.classList.add('active'); if (this.elements.settingsNav) this.elements.settingsNav.classList.add('active'); diff --git a/frontend/templates/components/apps_section.html b/frontend/templates/components/apps_section.html new file mode 100644 index 00000000..4d082405 --- /dev/null +++ b/frontend/templates/components/apps_section.html @@ -0,0 +1,37 @@ +
+
+

App Settings

+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
diff --git a/frontend/templates/components/settings_section.html b/frontend/templates/components/settings_section.html index ac7aeeed..1967cd0b 100644 --- a/frontend/templates/components/settings_section.html +++ b/frontend/templates/components/settings_section.html @@ -1,23 +1,6 @@
- -
-
- - -
-
+

General Settings

diff --git a/frontend/templates/components/sidebar.html b/frontend/templates/components/sidebar.html index 86d955c7..e716aaff 100644 --- a/frontend/templates/components/sidebar.html +++ b/frontend/templates/components/sidebar.html @@ -17,6 +17,10 @@ History + + + Apps + Settings diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 87b1e427..4080258b 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -20,6 +20,9 @@ {% include 'components/history_section.html' %} + + {% include 'components/apps_section.html' %} + {% include 'components/settings_section.html' %} @@ -36,6 +39,8 @@ + + \ No newline at end of file diff --git a/src/primary/web_server.py b/src/primary/web_server.py index c8053210..f24fdc3d 100644 --- a/src/primary/web_server.py +++ b/src/primary/web_server.py @@ -346,96 +346,75 @@ def logs_stream(): response.headers['X-Accel-Buffering'] = 'no' # Disable nginx buffering if using nginx return response -@app.route('/api/settings', methods=['GET', 'POST']) +@app.route('/api/settings', methods=['GET']) def api_settings(): if request.method == 'GET': # Return all settings using the new manager function all_settings = settings_manager.get_all_settings() # Corrected function name return jsonify(all_settings) - elif request.method == 'POST': - data = request.json - web_logger = get_logger("web_server") - web_logger.debug(f"Received settings save request: {data}") - - # Expecting data format like: { "sonarr": { "api_url": "...", ... } } - if not isinstance(data, dict) or len(data) != 1: - return jsonify({"success": False, "error": "Invalid payload format. Expected {'app_name': {settings...}}"}), 400 - - app_name = list(data.keys())[0] - settings_data = data[app_name] - - if app_name not in settings_manager.KNOWN_APP_TYPES: # Corrected attribute name - # Allow saving settings for potentially unknown apps if needed, or return error - # For now, let's restrict to known types - return jsonify({"success": False, "error": f"Unknown application type: {app_name}"}), 400 - - # Save the settings using the manager - success = settings_manager.save_settings(app_name, settings_data) # Corrected function name - - if success: - # ---> ADDED: Update stateful expiration if general settings were saved <--- - if app_name == 'general': - try: - new_hours = int(settings_data.get('stateful_management_hours')) - if new_hours > 0: - web_logger.info(f"General settings saved, updating stateful expiration to {new_hours} hours.") - update_lock_expiration(hours=new_hours) - else: - web_logger.warning("Invalid stateful_management_hours value received, not updating expiration.") - except (ValueError, TypeError) as e: - web_logger.error(f"Could not update stateful expiration after saving general settings: {e}") - except Exception as e: - web_logger.error(f"Unexpected error updating stateful expiration: {e}") - # ---> END ADDED SECTION <--- - - # Return the full updated configuration - all_settings = settings_manager.get_all_settings() # Corrected: Use get_all_settings - return jsonify(all_settings) # Return the full config object - else: - return jsonify({"success": False, "error": f"Failed to save settings for {app_name}"}), 500 - @app.route('/api/settings/general', methods=['POST']) def save_general_settings(): general_logger = get_logger("web_server") general_logger.info("Received request to save general settings.") - try: - data = request.get_json() - if not data or 'general' not in data: - general_logger.error("Invalid payload received for saving general settings.") - return jsonify({"success": False, "error": "Invalid payload"}), 400 - - general_settings_data = data['general'] - general_logger.debug(f"Saving general settings data: {general_settings_data}") - - # Save the entire general settings dictionary - success = settings_manager.save_settings('general', general_settings_data) + + # Make sure we have data + if not request.is_json: + return jsonify({"success": False, "error": "Expected JSON data"}), 400 + + data = request.json + + # Save general settings + success = settings_manager.save_settings('general', data) + + if success: + # Update expiration timing from general settings if applicable + try: + new_hours = int(data.get('stateful_management_hours')) + if new_hours > 0: + general_logger.info(f"Updating stateful expiration to {new_hours} hours.") + update_lock_expiration(hours=new_hours) + except (ValueError, TypeError, KeyError): + # Don't update if the value wasn't provided or is invalid + pass + except Exception as e: + general_logger.error(f"Error updating expiration timing: {e}") + + # Return all settings + return jsonify(settings_manager.get_all_settings()) + else: + return jsonify({"success": False, "error": "Failed to save general settings"}), 500 +@app.route('/api/settings/', methods=['GET', 'POST']) +def handle_app_settings(app_name): + web_logger = get_logger("web_server") + + # Validate app_name + if app_name not in settings_manager.KNOWN_APP_TYPES: + return jsonify({"success": False, "error": f"Unknown application type: {app_name}"}), 400 + + if request.method == 'GET': + # Return settings for the specific app + app_settings = settings_manager.load_settings(app_name) + return jsonify(app_settings) + + elif request.method == 'POST': + # Make sure we have data + if not request.is_json: + return jsonify({"success": False, "error": "Expected JSON data"}), 400 + + data = request.json + web_logger.debug(f"Received {app_name} settings save request: {data}") + + # Save the app settings + success = settings_manager.save_settings(app_name, data) + if success: - # ---> ADDED: Update stateful expiration if general settings were saved <--- - try: - new_hours = int(general_settings_data.get('stateful_management_hours')) - if new_hours > 0: - general_logger.info(f"General settings saved, updating stateful expiration to {new_hours} hours.") - update_lock_expiration(hours=new_hours) - else: - general_logger.warning("Invalid stateful_management_hours value received, not updating expiration.") - except (ValueError, TypeError) as e: - general_logger.error(f"Could not update stateful expiration after saving general settings: {e}") - except Exception as e: - general_logger.error(f"Unexpected error updating stateful expiration: {e}") - # ---> END ADDED SECTION <--- - - general_logger.info("General settings saved successfully.") - # Return the full updated config - full_config = settings_manager.load_settings() - return jsonify(full_config) + web_logger.info(f"Successfully saved {app_name} settings") + return jsonify({"success": True}) else: - general_logger.error("Failed to save general settings via settings_manager.") - return jsonify({"success": False, "error": "Failed to save settings"}), 500 - except Exception as e: - general_logger.error(f"Error saving general settings: {e}", exc_info=True) - return jsonify({"success": False, "error": str(e)}), 500 + web_logger.error(f"Failed to save {app_name} settings") + return jsonify({"success": False, "error": f"Failed to save {app_name} settings"}), 500 @app.route('/api/settings/theme', methods=['GET', 'POST']) def api_theme():