This commit is contained in:
Admin9705
2025-05-02 12:50:17 -04:00
parent 19fb1ca2e4
commit 976f04e9f8
8 changed files with 590 additions and 103 deletions

View File

@@ -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;
}

308
frontend/static/js/apps.js Normal file
View File

@@ -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 = '<div class="error-panel"><i class="fas fa-exclamation-triangle"></i> Failed to load app settings. Please try again.</div>';
}
});
},
// 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 = `<div class="settings-message">Settings for ${app.charAt(0).toUpperCase() + app.slice(1)} are not available.</div>`;
}
} else {
console.error('SettingsForms module not found');
settingsForm.innerHTML = '<div class="error-panel">Unable to generate settings form. Please reload the page.</div>';
}
},
// 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();
});

View File

@@ -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');

View File

@@ -0,0 +1,37 @@
<section id="appsSection" class="content-section">
<div class="section-header">
<h2>App Settings</h2>
<button id="saveAppsButton" class="save-button" disabled>
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="log-dropdown-container">
<div class="log-dropdown">
<button class="log-dropdown-btn">
<span id="current-apps-app">Sonarr</span>
<i class="fas fa-chevron-down"></i>
</button>
<div class="log-dropdown-content">
<a href="#" class="log-option active" data-app="sonarr">Sonarr</a>
<a href="#" class="log-option" data-app="radarr">Radarr</a>
<a href="#" class="log-option" data-app="lidarr">Lidarr</a>
<a href="#" class="log-option" data-app="readarr">Readarr</a>
<a href="#" class="log-option" data-app="whisparr">Whisparr</a>
<a href="#" class="log-option" data-app="swaparr">Swaparr</a>
</div>
</div>
</div>
<div class="settings-group">
<div class="settings-content">
<!-- App forms will be loaded here dynamically -->
<div id="sonarrApps" class="app-apps-panel active" style="display: block;"></div>
<div id="radarrApps" class="app-apps-panel"></div>
<div id="lidarrApps" class="app-apps-panel"></div>
<div id="readarrApps" class="app-apps-panel"></div>
<div id="whisparrApps" class="app-apps-panel"></div>
<div id="swaparrApps" class="app-apps-panel"></div>
</div>
</div>
</section>

View File

@@ -1,23 +1,6 @@
<section id="settingsSection" class="content-section">
<div class="section-header">
<!-- Replace settings tabs with dropdown -->
<div class="settings-dropdown-container">
<div class="settings-dropdown">
<button class="settings-dropdown-btn">
<span id="current-settings-app">General</span>
<i class="fas fa-chevron-down"></i>
</button>
<div class="settings-dropdown-content">
<a href="#" class="settings-option active" data-app="general">General</a>
<a href="#" class="settings-option" data-app="sonarr">Sonarr</a>
<a href="#" class="settings-option" data-app="radarr">Radarr</a>
<a href="#" class="settings-option" data-app="lidarr">Lidarr</a>
<a href="#" class="settings-option" data-app="readarr">Readarr</a>
<a href="#" class="settings-option" data-app="whisparr">Whisparr</a>
<a href="#" class="settings-option" data-app="swaparr">Swaparr</a>
</div>
</div>
</div>
<h2>General Settings</h2>
<div class="settings-actions">
<button id="saveSettingsButton" class="save-button" disabled>
@@ -28,12 +11,6 @@
<div class="settings-form">
<div id="generalSettings" class="app-settings-panel active" style="display: block;"></div>
<div id="sonarrSettings" class="app-settings-panel"></div>
<div id="radarrSettings" class="app-settings-panel"></div>
<div id="lidarrSettings" class="app-settings-panel"></div>
<div id="readarrSettings" class="app-settings-panel"></div>
<div id="whisparrSettings" class="app-settings-panel"></div>
<div id="swaparrSettings" class="app-settings-panel"></div>
</div>
</section>

View File

@@ -17,6 +17,10 @@
<i class="fas fa-history"></i>
<span>History</span>
</a>
<a href="#apps" class="nav-item" id="appsNav">
<i class="fas fa-tools"></i>
<span>Apps</span>
</a>
<a href="#settings" class="nav-item" id="settingsNav">
<i class="fas fa-cog"></i>
<span>Settings</span>

View File

@@ -20,6 +20,9 @@
<!-- History Section -->
{% include 'components/history_section.html' %}
<!-- Apps Section -->
{% include 'components/apps_section.html' %}
<!-- Settings Section -->
{% include 'components/settings_section.html' %}
@@ -36,6 +39,8 @@
<script src="/static/js/settings_forms.js"></script>
<!-- Load history script -->
<script src="/static/js/history.js"></script>
<!-- Load apps script -->
<script src="/static/js/apps.js"></script>
</body>
</html>

View File

@@ -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/<app_name>', 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():