Application backups were driven by one crontab entry per app, each offset by id * CFG_BACKUP_CRONTAB_APP_INTERVAL minutes. That minute offset is written straight into cron's 0-59 minute field, so past ~20 apps it overflowed into an invalid entry that silently never fired, and the fixed spacing could not serialize backups that ran longer than the gap. Replace it with a single daily entry (`libreportal backup scheduled`) that enqueues a backup task per enabled app. The existing systemd task processor drains them serially — no minute overflow, real serialization, and backups are now visible/cancellable in the Tasks UI. Per-app enable is read from CFG_<APP>_BACKUP at schedule time instead of being mirrored into crontab. Removes the stagger machinery (timing/setup/check/remove scripts), the now-unused cron_jobs table + insert, and the CFG_BACKUP_CRONTAB_APP_INTERVAL config knob and its WebUI field. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
1536 lines
60 KiB
JavaScript
Executable File
1536 lines
60 KiB
JavaScript
Executable File
// Config Shared Functions - Common functionality used across all config components
|
||
class ConfigShared {
|
||
|
||
// Toggle switch system - handles different types of toggles with proper layout
|
||
static createToggleSwitch(fieldId, key, value, title, description, options = {}) {
|
||
const isChecked = value === 'true';
|
||
const tooltipHtml = description ? `<span class="tooltip" title="${description}">ℹ️</span>` : '';
|
||
|
||
// Determine toggle type and layout
|
||
const toggleType = options.type || 'standard';
|
||
const layout = options.layout || 'inline';
|
||
|
||
let toggleHTML = '';
|
||
|
||
switch (toggleType) {
|
||
case 'master':
|
||
toggleHTML = this.createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options);
|
||
break;
|
||
case 'section':
|
||
toggleHTML = this.createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options);
|
||
break;
|
||
case 'standard':
|
||
default:
|
||
toggleHTML = this.createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options);
|
||
break;
|
||
}
|
||
|
||
// Wrap in layout container
|
||
return this.wrapToggleInLayout(toggleHTML, layout, options);
|
||
}
|
||
|
||
// Standard toggle switch (most common)
|
||
static createStandardToggle(fieldId, key, title, isChecked, tooltipHtml, options) {
|
||
return `
|
||
<div class="checkbox-field">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="${fieldId}" name="${key}" value="true" ${isChecked ? 'checked' : ''} onchange="ConfigShared.handleToggleChange(this, '${key}', '${options.category || ''}')">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${title}
|
||
${tooltipHtml}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Master toggle (for enabling/disabling entire sections)
|
||
static createMasterToggle(fieldId, key, title, isChecked, tooltipHtml, options) {
|
||
return `
|
||
<div class="field-group">
|
||
<label for="${fieldId}">
|
||
${title}
|
||
${tooltipHtml}
|
||
</label>
|
||
<label class="checkbox-label master-toggle">
|
||
<input type="checkbox" id="${fieldId}" name="${key}" value="true" ${isChecked ? 'checked' : ''} onchange="ConfigShared.handleMasterToggle(this, '${options.sectionId || ''}', '${options.fieldIds || []}')">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${title}
|
||
${tooltipHtml}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Section toggle (for showing/hiding sections)
|
||
static createSectionToggle(fieldId, key, title, isChecked, tooltipHtml, options) {
|
||
return `
|
||
<div class="hidden-options-toggle">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="${fieldId}" name="${key}" value="true" ${isChecked ? 'checked' : ''} onchange="ConfigShared.handleSectionToggle(this, '${options.sectionId || ''}')">
|
||
<span class="checkbox-custom"></span>
|
||
${title}
|
||
</label>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Wrap toggle in layout container
|
||
static wrapToggleInLayout(toggleHTML, layout, options) {
|
||
switch (layout) {
|
||
case 'grid':
|
||
return `<div class="toggle-grid">${toggleHTML}</div>`;
|
||
case 'flex':
|
||
return `<div class="toggle-flex">${toggleHTML}</div>`;
|
||
case 'inline':
|
||
default:
|
||
return toggleHTML;
|
||
}
|
||
}
|
||
|
||
// Auto-detect and create appropriate field type
|
||
static createSmartField(fieldId, key, value, title, description, options = {}) {
|
||
//console.log(`createSmartField: key=${key}, value=${value}, config=${!!options.config}`);
|
||
|
||
// Check if value is boolean (true/false strings)
|
||
const isBoolean = value === 'true' || value === 'false';
|
||
|
||
if (isBoolean) {
|
||
// Auto-create toggle switch for boolean values
|
||
return this.createToggleSwitch(fieldId, key, value, title, description, {
|
||
type: options.type || 'standard',
|
||
layout: options.layout || 'inline',
|
||
category: options.category || '',
|
||
sectionId: options.sectionId || '',
|
||
fieldIds: options.fieldIds || []
|
||
});
|
||
}
|
||
|
||
// Fall back to regular field generation for non-boolean values
|
||
return this.generateField(fieldId, key, value, title, description, options.selectOptions, options.config);
|
||
}
|
||
|
||
// Handle standard toggle changes
|
||
static handleToggleChange(checkbox, key, category) {
|
||
//console.log(`Toggle changed: ${key} = ${checkbox.checked} (category: ${category})`);
|
||
|
||
// Trigger custom event for other components to listen to
|
||
const event = new CustomEvent('configToggleChanged', {
|
||
detail: { key, value: checkbox.checked, category }
|
||
});
|
||
document.dispatchEvent(event);
|
||
|
||
// Auto-save if enabled
|
||
if (checkbox.dataset.autoSave === 'true') {
|
||
this.saveToggleValue(key, checkbox.checked);
|
||
}
|
||
}
|
||
|
||
// Handle master toggle changes (enables/disables multiple fields)
|
||
static handleMasterToggle(checkbox, sectionId, fieldIds) {
|
||
//console.log(`Master toggle changed: ${sectionId} = ${checkbox.checked}`);
|
||
|
||
// Enable/disable related fields
|
||
if (fieldIds && fieldIds.length > 0) {
|
||
fieldIds.forEach(fieldId => {
|
||
const field = document.getElementById(`config-${fieldId}`);
|
||
if (field) {
|
||
field.disabled = !checkbox.checked;
|
||
field.closest('.config-field')?.classList.toggle('disabled', !checkbox.checked);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Show/hide section content
|
||
if (sectionId) {
|
||
const sectionContent = document.getElementById(`${sectionId}-content`);
|
||
if (sectionContent) {
|
||
sectionContent.style.display = checkbox.checked ? 'block' : 'none';
|
||
}
|
||
}
|
||
|
||
// Trigger custom event
|
||
const event = new CustomEvent('configMasterToggleChanged', {
|
||
detail: { sectionId, enabled: checkbox.checked, fieldIds }
|
||
});
|
||
document.dispatchEvent(event);
|
||
}
|
||
|
||
// Handle section toggle changes (shows/hides sections)
|
||
static handleSectionToggle(checkbox, sectionId) {
|
||
//console.log(`Section toggle changed: ${sectionId} = ${checkbox.checked}`);
|
||
|
||
const content = document.getElementById(`${sectionId}-content`);
|
||
if (content) {
|
||
content.style.display = checkbox.checked ? 'block' : 'none';
|
||
}
|
||
|
||
// Trigger custom event
|
||
const event = new CustomEvent('configSectionToggleChanged', {
|
||
detail: { sectionId, visible: checkbox.checked }
|
||
});
|
||
document.dispatchEvent(event);
|
||
}
|
||
|
||
// Save toggle value immediately
|
||
static async saveToggleValue(key, value) {
|
||
try {
|
||
// For now, just log the change - local implementation
|
||
//console.log('Toggle value changed:', key, value ? 'true' : 'false');
|
||
|
||
// TODO: Implement local config file update
|
||
// This would require backend integration to write to actual config files
|
||
|
||
} catch (error) {
|
||
console.error('Error saving toggle value:', error);
|
||
}
|
||
}
|
||
|
||
// Create custom range field with start-end inputs
|
||
static createRangeField(fieldId, key, value, title, description) {
|
||
// Parse range value (format: "start-end")
|
||
let startRange = '';
|
||
let endRange = '';
|
||
|
||
if (value && value.includes('-')) {
|
||
const parts = value.split('-');
|
||
startRange = parts[0] || '';
|
||
endRange = parts[1] || '';
|
||
}
|
||
|
||
return `
|
||
<div class="range-field" style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<input type="number"
|
||
id="${fieldId}-start"
|
||
class="form-control"
|
||
placeholder="Start"
|
||
value="${startRange}"
|
||
min="1"
|
||
max="65535"
|
||
onchange="ConfigShared.updateRangeValue('${key}')"
|
||
style="flex: 1; min-width: 0;">
|
||
<span style="color: #6c757d; font-weight: bold; font-size: 16px; padding: 0 4px;">-</span>
|
||
<input type="number"
|
||
id="${fieldId}-end"
|
||
class="form-control"
|
||
placeholder="End"
|
||
value="${endRange}"
|
||
min="1"
|
||
max="65535"
|
||
onchange="ConfigShared.updateRangeValue('${key}')"
|
||
style="flex: 1; min-width: 0;">
|
||
<input type="hidden" id="${fieldId}" name="${key}" value="${value}">
|
||
</div>
|
||
<style>
|
||
.range-field {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
.range-field input[type="number"] {
|
||
text-align: center;
|
||
}
|
||
.range-field span {
|
||
color: var(--text-secondary, #6c757d);
|
||
font-weight: bold;
|
||
font-size: 16px;
|
||
user-select: none;
|
||
}
|
||
</style>
|
||
`;
|
||
}
|
||
|
||
// Update range value when inputs change
|
||
static updateRangeValue(key) {
|
||
const startInput = document.getElementById(`config-${key}-start`);
|
||
const endInput = document.getElementById(`config-${key}-end`);
|
||
const hiddenInput = document.getElementById(`config-${key}`);
|
||
|
||
if (startInput && endInput && hiddenInput) {
|
||
const startValue = startInput.value || '';
|
||
const endValue = endInput.value || '';
|
||
|
||
if (startValue && endValue) {
|
||
hiddenInput.value = `${startValue}-${endValue}`;
|
||
} else if (startValue) {
|
||
hiddenInput.value = `${startValue}-`;
|
||
} else if (endValue) {
|
||
hiddenInput.value = `-${endValue}`;
|
||
} else {
|
||
hiddenInput.value = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create custom crontab field with hour/minutes/AM-PM
|
||
static createCrontabField(fieldId, key, value, title, description) {
|
||
// Parse crontab value (format: "minute hour * * *")
|
||
const parts = value.split(' ');
|
||
let hour = '5';
|
||
let minute = '0';
|
||
let period = 'AM';
|
||
|
||
if (parts.length >= 2) {
|
||
minute = parts[0] || '0';
|
||
const cronHour = parseInt(parts[1]) || 0;
|
||
|
||
// Convert 24-hour to 12-hour format
|
||
if (cronHour === 0) {
|
||
hour = '12';
|
||
period = 'AM';
|
||
} else if (cronHour < 12) {
|
||
hour = cronHour.toString();
|
||
period = 'AM';
|
||
} else if (cronHour === 12) {
|
||
hour = '12';
|
||
period = 'PM';
|
||
} else {
|
||
hour = (cronHour - 12).toString();
|
||
period = 'PM';
|
||
}
|
||
}
|
||
|
||
return `
|
||
<div class="crontab-field" style="display: flex; gap: 4px; align-items: center; width: 100%;">
|
||
<select id="${fieldId}-hour" class="form-control" onchange="ConfigShared.updateCrontabValue('${key}')" style="flex: 1; min-width: 0;">
|
||
${Array.from({length: 12}, (_, i) => i + 1).map(h =>
|
||
`<option value="${h}" ${h === parseInt(hour) ? 'selected' : ''}>${h}</option>`
|
||
).join('')}
|
||
</select>
|
||
<span style="color: #6c757d; font-weight: bold; font-size: 16px; padding: 0 2px;">:</span>
|
||
<select id="${fieldId}-minute" class="form-control" onchange="ConfigShared.updateCrontabValue('${key}')" style="flex: 1; min-width: 0;">
|
||
${Array.from({length: 60}, (_, i) => i.toString().padStart(2, '0')).map(m =>
|
||
`<option value="${m}" ${m === minute ? 'selected' : ''}>${m}</option>`
|
||
).join('')}
|
||
</select>
|
||
<span style="color: #6c757d; font-weight: bold; font-size: 16px; padding: 0 2px;">/</span>
|
||
<select id="${fieldId}-period" class="form-control" onchange="ConfigShared.updateCrontabValue('${key}')" style="flex: 1; min-width: 0;">
|
||
<option value="AM" ${period === 'AM' ? 'selected' : ''}>AM</option>
|
||
<option value="PM" ${period === 'PM' ? 'selected' : ''}>PM</option>
|
||
</select>
|
||
<input type="hidden" id="${fieldId}" name="${key}" value="${value}">
|
||
</div>
|
||
<style>
|
||
.crontab-field {
|
||
margin-bottom: 0 !important;
|
||
}
|
||
.crontab-field select {
|
||
font-size: 14px;
|
||
height: 38px;
|
||
}
|
||
</style>
|
||
`;
|
||
}
|
||
|
||
// Update crontab value when fields change
|
||
static updateCrontabValue(key) {
|
||
const fieldId = `config-${key}`;
|
||
const hourSelect = document.getElementById(`${fieldId}-hour`);
|
||
const minuteSelect = document.getElementById(`${fieldId}-minute`);
|
||
const periodSelect = document.getElementById(`${fieldId}-period`);
|
||
const hiddenInput = document.getElementById(fieldId);
|
||
|
||
if (!hourSelect || !minuteSelect || !periodSelect || !hiddenInput) {
|
||
console.warn(`Crontab field elements not found for: ${key}`);
|
||
return;
|
||
}
|
||
|
||
const hour = parseInt(hourSelect.value);
|
||
const minute = minuteSelect.value;
|
||
const period = periodSelect.value;
|
||
|
||
// Convert 12-hour to 24-hour format
|
||
let cronHour;
|
||
if (period === 'AM') {
|
||
cronHour = hour === 12 ? 0 : hour;
|
||
} else {
|
||
cronHour = hour === 12 ? 12 : hour + 12;
|
||
}
|
||
|
||
// Create crontab format: "minute hour * * *"
|
||
const crontabValue = `${minute} ${cronHour} * * *`;
|
||
hiddenInput.value = crontabValue;
|
||
|
||
//console.log(`Updated crontab for ${key}: ${crontabValue}`);
|
||
}
|
||
|
||
// Toggle password visibility
|
||
static togglePasswordVisibility(fieldId) {
|
||
const passwordField = document.getElementById(fieldId);
|
||
const icon = document.getElementById(`${fieldId}-icon`);
|
||
|
||
if (!passwordField || !icon) {
|
||
console.warn(`Password field or icon not found: ${fieldId}`);
|
||
return;
|
||
}
|
||
|
||
if (passwordField.type === 'password') {
|
||
passwordField.type = 'text';
|
||
icon.textContent = '👁🗨'; // Eye with strikethrough (hidden)
|
||
} else {
|
||
passwordField.type = 'password';
|
||
icon.textContent = '👁'; // Regular eye (visible)
|
||
}
|
||
}
|
||
|
||
static setPasswordMode(fieldId, mode) {
|
||
const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`);
|
||
const input = document.getElementById(fieldId);
|
||
const tokenInput = document.getElementById(`${fieldId}-token`);
|
||
if (!wrapper || !input || !tokenInput) return;
|
||
|
||
const key = wrapper.dataset.fieldKey;
|
||
|
||
if (mode === 'random') {
|
||
input.dataset.previousCustom = input.value || '';
|
||
input.value = '';
|
||
input.readOnly = true;
|
||
input.type = 'password';
|
||
input.setAttribute('placeholder', 'Will generate on save');
|
||
input.removeAttribute('name');
|
||
tokenInput.setAttribute('name', key);
|
||
const icon = document.getElementById(`${fieldId}-icon`);
|
||
if (icon) icon.textContent = '👁';
|
||
} else {
|
||
input.readOnly = false;
|
||
input.removeAttribute('placeholder');
|
||
input.value = input.dataset.previousCustom || '';
|
||
input.setAttribute('name', key);
|
||
tokenInput.removeAttribute('name');
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
// Format config key to readable label
|
||
static formatConfigLabel(key) {
|
||
// Special handling for domain configuration
|
||
if (key.startsWith('CFG_DOMAIN_')) {
|
||
const domainNum = key.replace('CFG_DOMAIN_', '');
|
||
return `Domain ${domainNum}`;
|
||
}
|
||
|
||
// Special handling for requirement configuration - remove REQUIREMENT and format nicely
|
||
if (key.startsWith('CFG_REQUIREMENT_')) {
|
||
const requirement = key.replace('CFG_REQUIREMENT_', '');
|
||
return requirement
|
||
.replace(/_/g, ' ')
|
||
.replace(/\b\w/g, l => l.toUpperCase());
|
||
}
|
||
|
||
return key
|
||
.replace(/^CFG_/, '')
|
||
.replace(/_/g, ' ')
|
||
.replace(/\b\w/g, l => l.toUpperCase());
|
||
}
|
||
|
||
// Group config keys by category
|
||
static groupConfigKeys(config) {
|
||
const groups = {};
|
||
|
||
// Group by category field from JSON config
|
||
for (const key of Object.keys(config)) {
|
||
const configItem = config[key] || {};
|
||
const category = configItem.category;
|
||
|
||
if (!groups[category]) {
|
||
groups[category] = [];
|
||
}
|
||
groups[category].push(key);
|
||
}
|
||
|
||
// Pin any per-app `*_BACKUP` toggle to the top of its category so the
|
||
// user always sees it first when configuring an app. Matches both
|
||
// `CFG_BACKUP` and `CFG_<APP>_BACKUP` (but not `*_BACKUP_*` like
|
||
// `CFG_BACKUP_CRONTAB_APP`, which would otherwise bubble up too).
|
||
for (const cat of Object.keys(groups)) {
|
||
groups[cat].sort((a, b) => {
|
||
const aBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(a);
|
||
const bBackup = /^CFG_(?:[A-Z0-9]+_)?BACKUP$/.test(b);
|
||
if (aBackup && !bBackup) return -1;
|
||
if (!aBackup && bBackup) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
return groups;
|
||
}
|
||
|
||
// Extract category order from config
|
||
static extractCategoryOrder(config) {
|
||
const order = [];
|
||
const seen = new Set();
|
||
|
||
// Get categories in the order they appear in config
|
||
for (const key of Object.keys(config)) {
|
||
const configItem = config[key] || {};
|
||
const category = configItem.category;
|
||
|
||
if (category && !seen.has(category)) {
|
||
seen.add(category);
|
||
order.push(category);
|
||
}
|
||
}
|
||
|
||
return order;
|
||
}
|
||
|
||
// Format category name for display
|
||
static formatCategoryName(category) {
|
||
return category
|
||
.replace(/_/g, ' ')
|
||
.replace(/\b\w/g, l => l.toUpperCase());
|
||
}
|
||
|
||
/* Subcategory titles often come back as "<Category> <Sub>" — e.g.
|
||
"General Basic" under the General category, "Webui Logins" under
|
||
the Webui category. The redundant leading category word is noisy
|
||
when the user is already viewing that category page, so strip it.
|
||
Comparison is case-insensitive and only strips a clean leading
|
||
whole-word match (won't turn "Configuration" into "iguration"). */
|
||
static stripCategoryPrefix(title, parentCategory) {
|
||
if (!title || !parentCategory) return title;
|
||
const prefix = ConfigShared.formatCategoryName(parentCategory) + ' ';
|
||
if (title.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||
return title.substring(prefix.length);
|
||
}
|
||
return title;
|
||
}
|
||
|
||
// Get category description from config or fallback
|
||
static async getCategoryDescription(category) {
|
||
try {
|
||
// First try to get description from the unified config
|
||
const response = await fetch('/data/config/generated/configs.json');
|
||
const data = await response.json();
|
||
|
||
// Check if categories exist in this config
|
||
if (data.categories) {
|
||
// Categories are stored as simple key-value pairs: "CATEGORY": "description"
|
||
const categoryDescription = data.categories[category];
|
||
if (categoryDescription) {
|
||
return categoryDescription;
|
||
}
|
||
}
|
||
|
||
// Fallback to unified config for general descriptions
|
||
const unifiedConfigResponse = await fetch('/data/config/generated/configs.json');
|
||
const unifiedConfigData = await unifiedConfigResponse.json();
|
||
const fallbackCategoryData = unifiedConfigData.categories[category] || null;
|
||
|
||
return fallbackCategoryData ? fallbackCategoryData.description : `${this.formatCategoryName(category)} settings and configuration`;
|
||
} catch (error) {
|
||
console.error('Error loading category descriptions:', error);
|
||
return `${this.formatCategoryName(category)} settings and configuration`;
|
||
}
|
||
}
|
||
|
||
// Generate appropriate field based on value type and key
|
||
static generateField(fieldId, key, value, title, description, options = {}, allConfig = {}) {
|
||
//console.log(`generateField: key=${key}, value=${value}, CFG_INSTALL_MODE=${allConfig.CFG_INSTALL_MODE?.value}`);
|
||
|
||
// Note: Git fields (CFG_GIT_*) are now handled by the toggle system in renderGitSection
|
||
// They don't need to be hidden here since the section itself is toggled
|
||
|
||
// Handle boolean toggle fields from options
|
||
const { onchange, oninput, onblur, className, placeholder, ...fieldOptions } = options;
|
||
|
||
// Check if value is boolean (true/false strings)
|
||
const isBoolean = value === 'true' || value === 'false';
|
||
|
||
if (isBoolean) {
|
||
// Auto-create toggle switch for boolean values
|
||
return this.createToggleSwitch(fieldId, key, value, title, description, {
|
||
type: 'standard',
|
||
layout: 'inline',
|
||
category: ''
|
||
});
|
||
}
|
||
|
||
// Non-boolean fields - use exact old config structure
|
||
const tooltipHtml = description ? `<span class="tooltip" title="${description}">ℹ️</span>` : '';
|
||
|
||
let fieldHTML = `
|
||
<div class="field-group">
|
||
<label for="${fieldId}">
|
||
${title}
|
||
${tooltipHtml}
|
||
</label>
|
||
`;
|
||
|
||
// Determine field type based on key or options
|
||
////console.log(`Checking field type for key: ${key}`); // Debug log
|
||
////console.log(`Password check: ${key.includes('PASS') && !key.includes('LENGTH')}, ${key.includes('SECRET')}`); // Debug log
|
||
|
||
// Custom crontab fields for backup schedules
|
||
if (key === 'CFG_BACKUP_CRONTAB_APP') {
|
||
fieldHTML += this.createCrontabField(fieldId, key, value, title, description);
|
||
} else if (key.includes('PORT_RANGE')) {
|
||
fieldHTML += this.createRangeField(fieldId, key, value, title, description);
|
||
} else if ((key.includes('PASS') && !key.includes('LENGTH')) || key.includes('SECRET') || key.endsWith('_API_KEY') || key.endsWith('_PRIVATE_KEY')) {
|
||
////console.log(`Creating password field for: ${key}`); // Debug log
|
||
const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value);
|
||
const placeholderToken = randomMatch ? value : `RANDOMIZEDPASSWORD1`;
|
||
const initialMode = randomMatch ? 'random' : 'custom';
|
||
const inputValue = randomMatch ? '' : value;
|
||
const visibleName = initialMode === 'custom' ? `name="${key}"` : '';
|
||
const hiddenName = initialMode === 'random' ? `name="${key}"` : '';
|
||
fieldHTML += `
|
||
<div class="password-mode-wrapper" data-field-id="${fieldId}" data-field-key="${key}" data-placeholder-token="${placeholderToken}" style="display: flex; gap: 6px; align-items: stretch;">
|
||
<select class="form-control password-mode-select" style="max-width: 140px;" onchange="ConfigShared.setPasswordMode('${fieldId}', this.value)">
|
||
<option value="random" ${initialMode === 'random' ? 'selected' : ''}>Random</option>
|
||
<option value="custom" ${initialMode === 'custom' ? 'selected' : ''}>Custom</option>
|
||
</select>
|
||
<div class="password-input-wrapper" style="position: relative; flex: 1;">
|
||
<input type="password" id="${fieldId}" ${visibleName} value="${inputValue}" class="form-control password-field" style="padding-right: 35px;" ${initialMode === 'random' ? 'readonly placeholder="Will generate on save"' : ''}>
|
||
<input type="hidden" id="${fieldId}-token" ${hiddenName} value="${placeholderToken}">
|
||
<button class="password-toggle" type="button" onclick="togglePasswordVisibility('${fieldId}')" style="position: absolute; right: 8px; top: 50%; transform: translateY(-50%); border: none; background: transparent; cursor: pointer; padding: 4px; border-radius: 3px; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px;">
|
||
<span id="${fieldId}-icon" style="font-size: 14px; color: var(--text-primary); font-weight: bold; user-select: none;">👁</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<style>
|
||
.password-toggle {
|
||
opacity: 1 !important;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.password-toggle:focus {
|
||
outline: none;
|
||
box-shadow: none;
|
||
}
|
||
.password-toggle:active {
|
||
transform: translateY(-50%) scale(0.95);
|
||
}
|
||
.password-toggle span {
|
||
opacity: 1 !important;
|
||
color: var(--text-primary) !important;
|
||
}
|
||
.password-toggle:hover {
|
||
background-color: rgba(var(--text-rgb), 0.12) !important;
|
||
}
|
||
.password-toggle:active {
|
||
background-color: rgba(var(--text-rgb), 0.22) !important;
|
||
}
|
||
</style>
|
||
`;
|
||
} else if (key.includes('EMAIL') || key.includes('MAIL')) {
|
||
// Special handling for CFG_MAIL_SECURE - it's a dropdown
|
||
if (key === 'CFG_MAIL_SECURE') {
|
||
const selectOptions = ConfigOptions.getSelectOptions(key);
|
||
fieldHTML += `<select id="${fieldId}" name="${key}" class="form-control">${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}</select>`;
|
||
} else {
|
||
// Different validation for different mail fields
|
||
let validationAttrs = '';
|
||
let fieldType = 'email';
|
||
let placeholder = 'user@example.com';
|
||
|
||
if (key === 'CFG_MAIL_HOST') {
|
||
fieldType = 'text';
|
||
placeholder = 'mail.domain.com';
|
||
validationAttrs = `onchange="window.configManager.validateHostnameFormat(this, true)" oninput="window.configManager.validateHostnameFormat(this, true)" onblur="window.configManager.validateHostnameFormat(this, true)"`;
|
||
} else if (key === 'CFG_MAIL_PORT') {
|
||
fieldType = 'number';
|
||
placeholder = '587';
|
||
validationAttrs = `onchange="window.configManager.validatePortNumber(this, true)" oninput="window.configManager.validatePortNumber(this, true)" onblur="window.configManager.validatePortNumber(this, true)"`;
|
||
} else if (key === 'CFG_MAIL_USERNAME' || key === 'CFG_MAIL_FROM') {
|
||
validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`;
|
||
} else {
|
||
validationAttrs = `onchange="window.configManager.validateEmailFormat(this, true)" oninput="window.configManager.validateEmailFormat(this, true)" onblur="window.configManager.validateEmailFormat(this, true)"`;
|
||
}
|
||
|
||
fieldHTML += `
|
||
<input type="${fieldType}" id="${fieldId}" name="${key}" value="${value}" class="form-control" placeholder="${placeholder}" ${validationAttrs}>
|
||
`;
|
||
}
|
||
} else if (key.includes('URL') || key.includes('LINK') || key.includes('HOST') || key.startsWith('CFG_DOMAIN_')) {
|
||
const placeholder = key.startsWith('CFG_DOMAIN_') ? 'example.com' : 'https://example.com';
|
||
fieldHTML += `
|
||
<input type="url" id="${fieldId}" name="${key}" value="${value}" class="form-control" placeholder="${placeholder}">
|
||
`;
|
||
} else if (/^CFG_BACKUP(_LOC_[0-9]+)?_KEEP_(LAST|DAILY|WEEKLY|MONTHLY|YEARLY)$/.test(key)) {
|
||
const unitLabel = key.endsWith('_KEEP_LAST') ? 'snapshots' :
|
||
key.endsWith('_KEEP_DAILY') ? 'days' :
|
||
key.endsWith('_KEEP_WEEKLY') ? 'weeks' :
|
||
key.endsWith('_KEEP_MONTHLY') ? 'months' : 'years';
|
||
fieldHTML += `
|
||
<div class="input-group">
|
||
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="0" max="9999">
|
||
<span class="input-group-text">${unitLabel}</span>
|
||
</div>
|
||
`;
|
||
} else if (key === 'CFG_UPDATER_CHECK') {
|
||
fieldHTML += `
|
||
<div class="input-group">
|
||
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="1" max="600" placeholder="60">
|
||
<span class="input-group-text">minutes</span>
|
||
</div>
|
||
`;
|
||
} else if (key === 'CFG_GENERATED_PASS_LENGTH') {
|
||
fieldHTML += `
|
||
<div class="input-group">
|
||
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="8" max="128" placeholder="26">
|
||
<span class="input-group-text">characters</span>
|
||
</div>
|
||
`;
|
||
} else if (key === 'CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES') {
|
||
fieldHTML += `
|
||
<div class="input-group">
|
||
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="0" max="1440" placeholder="10">
|
||
<span class="input-group-text">minutes</span>
|
||
</div>
|
||
`;
|
||
} else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES') {
|
||
fieldHTML += `
|
||
<div class="input-group">
|
||
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="0" max="1440" placeholder="60">
|
||
<span class="input-group-text">minutes</span>
|
||
</div>
|
||
`;
|
||
} else if (key === 'CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC') {
|
||
fieldHTML += `
|
||
<div class="input-group">
|
||
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="1" max="10000" placeholder="200">
|
||
<span class="input-group-text">lines</span>
|
||
</div>
|
||
`;
|
||
} else if (key === 'CFG_SWAPFILE_SIZE') {
|
||
// Extract numeric value from "2G" format
|
||
let numericValue = value.replace(/[^0-9.]/g, '');
|
||
fieldHTML += `
|
||
<div class="input-group">
|
||
<input type="number" id="${fieldId}" name="${key}" value="${numericValue}" class="form-control" min="0" max="" placeholder="2">
|
||
<span class="input-group-text">GB</span>
|
||
</div>
|
||
`;
|
||
} else if (key.includes('SIZE') || key.includes('LENGTH') || key.includes('CHECK') || key.includes('MTU') || key.includes('PORT')) {
|
||
let min = '';
|
||
let max = '';
|
||
|
||
if (key.includes('PORT')) {
|
||
min = '1';
|
||
max = '65535';
|
||
} else if (key.includes('SIZE') || key.includes('LENGTH')) {
|
||
min = '0';
|
||
max = key.includes('PASS_LENGTH') ? '128' : '';
|
||
}
|
||
|
||
fieldHTML += `
|
||
<input type="number" id="${fieldId}" name="${key}" value="${value}" class="form-control" min="${min}" max="${max}">
|
||
`;
|
||
} else if (key.includes('TIMEZONE')) {
|
||
// Special handling for Timezone - create comprehensive timezone dropdown
|
||
const timezoneOptions = ConfigOptions.getTimezoneOptions();
|
||
//console.log('Timezone key:', key, 'Current value:', value, 'Type:', typeof value);
|
||
//console.log('Available timezone options:', timezoneOptions.map(opt => ({value: opt.value, label: opt.label})));
|
||
fieldHTML += `
|
||
<select id="${fieldId}" name="${key}" class="form-control">
|
||
${timezoneOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}
|
||
</select>
|
||
`;
|
||
//console.log('Generated timezone dropdown HTML for', key, 'with value', value);
|
||
} else if (key === 'CFG_INSTALL_MODE') {
|
||
const selectOptions = ConfigOptions.getSelectOptions(key);
|
||
fieldHTML += `<select id="${fieldId}" name="${key}" class="form-control" onchange="handleInstallModeChange(this)">${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}</select>`;
|
||
} else if (/^CFG_BACKUP_LOC_[0-9]+_TYPE$/.test(key)) {
|
||
const selectOptions = ConfigOptions.getSelectOptions(key);
|
||
fieldHTML += `<select id="${fieldId}" name="${key}" class="form-control">${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}</select>`;
|
||
} else if (ConfigOptions.isDropdownKey(key) || (options && Object.keys(options).length > 0)) {
|
||
//console.log('=== GENERIC DROPDOWN BLOCK ENTERED for key:', key);
|
||
//console.log('Dropdown detected for key:', key);
|
||
//console.log('isDropdownKey result:', ConfigOptions.isDropdownKey(key));
|
||
//console.log('options available:', options);
|
||
const selectOptions = (options && typeof options === 'string') ? this.parseOptions(options) : ConfigOptions.getSelectOptions(key);
|
||
//console.log('selectOptions:', selectOptions);
|
||
fieldHTML += `
|
||
<select id="${fieldId}" name="${key}" class="form-control">
|
||
${selectOptions.map(opt => `<option value="${opt.value}" ${opt.value === value ? 'selected' : ''}>${opt.label}</option>`).join('')}
|
||
</select>
|
||
`;
|
||
//console.log('Generated dropdown for', key, 'with value', value);
|
||
} else if (key.includes('DESCRIPTION') || key.includes('COMMENTS') || key.includes('NOTES')) {
|
||
//console.log('Textarea detected for key:', key);
|
||
fieldHTML += `
|
||
<textarea id="${fieldId}" name="${key}" class="form-control" rows="4">${value}</textarea>
|
||
`;
|
||
} else {
|
||
//console.log('Default text input for key:', key);
|
||
// Default text input with event handlers and options
|
||
const inputClass = className ? `form-control ${className}` : 'form-control';
|
||
const inputPlaceholder = placeholder || '';
|
||
const eventHandlers = [];
|
||
if (onchange) eventHandlers.push(`onchange="${onchange}"`);
|
||
if (oninput) eventHandlers.push(`oninput="${oninput}"`);
|
||
if (onblur) eventHandlers.push(`onblur="${onblur}"`);
|
||
const eventAttrs = eventHandlers.length > 0 ? ` ${eventHandlers.join(' ')}` : '';
|
||
|
||
fieldHTML += `
|
||
<input type="text" id="${fieldId}" name="${key}" value="${value}" class="${inputClass}" placeholder="${inputPlaceholder}"${eventAttrs}>
|
||
`;
|
||
}
|
||
|
||
fieldHTML += `
|
||
</div>
|
||
`;
|
||
|
||
return fieldHTML;
|
||
}
|
||
|
||
// Create master toggle for section enabling/disabling
|
||
static createMasterToggle(sectionId, masterKey, isEnabled, title, description) {
|
||
return `
|
||
<div class="master-toggle">
|
||
<div class="field-group">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="config-${masterKey}" name="${masterKey}" value="true" ${isEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSection('${sectionId}', this.checked)">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${title}
|
||
${description ? `<span class="tooltip" title="${description}">ℹ️</span>` : ''}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Create section content wrapper
|
||
static createSectionContent(sectionId, isEnabled) {
|
||
return `
|
||
<div id="section-content-${sectionId}" class="section-content ${isEnabled ? '' : 'disabled'}">
|
||
`;
|
||
}
|
||
|
||
// Toggle section fields (modular function)
|
||
static toggleSection(sectionId, isEnabled) {
|
||
const sectionContent = document.getElementById(`section-content-${sectionId}`);
|
||
|
||
if (!sectionContent) {
|
||
console.warn(`Section content not found: ${sectionId}`);
|
||
return;
|
||
}
|
||
|
||
const fields = sectionContent.querySelectorAll('input, select, textarea');
|
||
|
||
if (isEnabled) {
|
||
// Enable section
|
||
sectionContent.classList.remove('disabled');
|
||
fields.forEach(field => {
|
||
field.disabled = false;
|
||
const fieldGroup = field.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '1';
|
||
fieldGroup.style.pointerEvents = 'auto';
|
||
}
|
||
});
|
||
//console.log(`Section ${sectionId} enabled`);
|
||
} else {
|
||
// Disable section
|
||
sectionContent.classList.add('disabled');
|
||
fields.forEach(field => {
|
||
field.disabled = true;
|
||
const fieldGroup = field.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '0.6';
|
||
fieldGroup.style.pointerEvents = 'none';
|
||
}
|
||
});
|
||
//console.log(`Section ${sectionId} disabled`);
|
||
}
|
||
}
|
||
|
||
// Toggle section visibility (hide/show entire section)
|
||
static toggleSectionVisibility(sectionId, isEnabled) {
|
||
const sectionContent = document.getElementById(sectionId);
|
||
|
||
if (!sectionContent) {
|
||
console.warn(`Section content not found: ${sectionId}`);
|
||
return;
|
||
}
|
||
|
||
if (isEnabled) {
|
||
// Show section
|
||
sectionContent.classList.remove('hidden');
|
||
//console.log(`Section ${sectionId} shown`);
|
||
} else {
|
||
// Hide section
|
||
sectionContent.classList.add('hidden');
|
||
//console.log(`Section ${sectionId} hidden`);
|
||
}
|
||
}
|
||
|
||
// Initialize section toggles on page load
|
||
static initializeSectionToggles() {
|
||
// Find all master toggles and initialize their sections
|
||
document.querySelectorAll('[id^="config-CFG_"][onchange*="toggleSection"]').forEach(toggle => {
|
||
// Extract section ID from the onchange attribute
|
||
const onchangeAttr = toggle.getAttribute('onchange');
|
||
const match = onchangeAttr.match(/toggleSection\('([^']+)'/);
|
||
if (match) {
|
||
const sectionId = match[1];
|
||
const isEnabled = toggle.checked;
|
||
this.toggleSection(sectionId, isEnabled);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Git section toggle function (moved from global scope)
|
||
static toggleGitSectionFields(isEnabled) {
|
||
// If no parameter provided, get the state from the checkbox
|
||
if (typeof isEnabled === 'undefined') {
|
||
const gitLoginCheckbox = document.getElementById('git-login-toggle');
|
||
isEnabled = gitLoginCheckbox ? gitLoginCheckbox.checked : false;
|
||
}
|
||
|
||
const gitSectionContent = document.getElementById('git-section-content');
|
||
const gitFields = gitSectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
|
||
|
||
if (gitSectionContent && gitFields) {
|
||
if (isEnabled) {
|
||
gitSectionContent.classList.remove('hidden');
|
||
gitFields.forEach(field => {
|
||
field.disabled = false;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '1';
|
||
fieldGroup.style.pointerEvents = 'auto';
|
||
}
|
||
});
|
||
} else {
|
||
gitSectionContent.classList.add('hidden');
|
||
gitFields.forEach(field => {
|
||
field.disabled = true;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '0.5';
|
||
fieldGroup.style.pointerEvents = 'none';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Force re-initialization of form to ensure proper state
|
||
setTimeout(() => {
|
||
const form = document.getElementById('config-form');
|
||
if (form) {
|
||
// Trigger a change event to update any dependent fields
|
||
const event = new Event('change', { bubbles: true });
|
||
gitSectionContent?.dispatchEvent(event);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// Universal toggle function for all _ENABLED options
|
||
static toggleSection(sectionId, isEnabled) {
|
||
//console.log('=== UNIVERSAL TOGGLE DEBUG ===');
|
||
//console.log('sectionId:', sectionId);
|
||
//console.log('isEnabled:', isEnabled);
|
||
|
||
const sectionContent = document.getElementById(sectionId);
|
||
const fields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
|
||
|
||
//console.log('sectionContent found:', !!sectionContent);
|
||
//console.log('fields found:', fields ? fields.length : 0);
|
||
|
||
if (sectionContent && fields) {
|
||
if (isEnabled) {
|
||
//console.log('Enabling section...');
|
||
sectionContent.classList.remove('hidden');
|
||
fields.forEach((field, index) => {
|
||
//console.log(`Enabling field ${index}:`, field);
|
||
field.disabled = false;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '1';
|
||
fieldGroup.style.pointerEvents = 'auto';
|
||
}
|
||
});
|
||
} else {
|
||
//console.log('Disabling section...');
|
||
sectionContent.classList.add('hidden');
|
||
fields.forEach((field, index) => {
|
||
//console.log(`Disabling field ${index}:`, field);
|
||
field.disabled = true;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '0.5';
|
||
fieldGroup.style.pointerEvents = 'none';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
//console.log('=== UNIVERSAL TOGGLE DEBUG END ===');
|
||
}
|
||
|
||
// Remote backup section toggle function
|
||
static toggleMailSection(sectionId, isEnabled) {
|
||
//console.log('=== TOGGLE MAIL SECTION DEBUG ===');
|
||
//console.log('sectionId:', sectionId);
|
||
//console.log('isEnabled:', isEnabled);
|
||
|
||
alert('toggleMailSection called: ' + sectionId + ', enabled: ' + isEnabled);
|
||
|
||
//console.log('Looking for sectionContent...');
|
||
const sectionContent = document.getElementById(sectionId);
|
||
//console.log('sectionContent found:', !!sectionContent);
|
||
|
||
//console.log('Looking for mailFields...');
|
||
const mailFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
|
||
//console.log('mailFields found:', mailFields ? mailFields.length : 0);
|
||
|
||
if (sectionContent && mailFields) {
|
||
//console.log('Enabling mail section...');
|
||
if (isEnabled) {
|
||
//console.log('Removing hidden class...');
|
||
sectionContent.classList.remove('hidden');
|
||
//console.log('Enabling fields...');
|
||
mailFields.forEach((field, index) => {
|
||
//console.log(`Disabling field ${index}:`, field);
|
||
field.disabled = false;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
//console.log('Enabling field group...');
|
||
fieldGroup.style.opacity = '1';
|
||
fieldGroup.style.pointerEvents = 'auto';
|
||
}
|
||
});
|
||
} else {
|
||
//console.log('Disabling mail section...');
|
||
sectionContent.classList.add('hidden');
|
||
//console.log('Disabling fields...');
|
||
mailFields.forEach((field, index) => {
|
||
//console.log(`Enabling field ${index}:`, field);
|
||
field.disabled = true;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
//console.log('Disabling field group...');
|
||
fieldGroup.style.opacity = '0.5';
|
||
fieldGroup.style.pointerEvents = 'none';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
//console.log('=== TOGGLE MAIL SECTION DEBUG END ===');
|
||
}
|
||
|
||
static toggleRemoteBackupSection(sectionId, isEnabled) {
|
||
const sectionContent = document.getElementById(sectionId);
|
||
const backupFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
|
||
|
||
if (sectionContent && backupFields) {
|
||
if (isEnabled) {
|
||
sectionContent.classList.remove('hidden');
|
||
backupFields.forEach(field => {
|
||
field.disabled = false;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '1';
|
||
fieldGroup.style.pointerEvents = 'auto';
|
||
}
|
||
});
|
||
} else {
|
||
sectionContent.classList.add('hidden');
|
||
backupFields.forEach(field => {
|
||
field.disabled = true;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '0.5';
|
||
fieldGroup.style.pointerEvents = 'none';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Force re-initialization of form to ensure proper state
|
||
setTimeout(() => {
|
||
const form = document.getElementById('config-form');
|
||
if (form) {
|
||
// Trigger a change event to update any dependent fields
|
||
const event = new Event('change', { bubbles: true });
|
||
sectionContent?.dispatchEvent(event);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// Git section toggle function
|
||
static toggleGitSection(sectionId, isEnabled) {
|
||
const sectionContent = document.getElementById(sectionId);
|
||
const gitFields = sectionContent?.querySelectorAll('.config-fields input, .config-fields select, .config-fields textarea');
|
||
|
||
if (sectionContent && gitFields) {
|
||
if (isEnabled) {
|
||
sectionContent.classList.remove('hidden');
|
||
gitFields.forEach(field => {
|
||
field.disabled = false;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '1';
|
||
fieldGroup.style.pointerEvents = 'auto';
|
||
}
|
||
});
|
||
} else {
|
||
sectionContent.classList.add('hidden');
|
||
gitFields.forEach(field => {
|
||
field.disabled = true;
|
||
const fieldGroup = field?.closest('.field-group');
|
||
if (fieldGroup) {
|
||
fieldGroup.style.opacity = '0.5';
|
||
fieldGroup.style.pointerEvents = 'none';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Force re-initialization of form to ensure proper state
|
||
setTimeout(() => {
|
||
const form = document.getElementById('config-form');
|
||
if (form) {
|
||
// Trigger a change event to update any dependent fields
|
||
const event = new Event('change', { bubbles: true });
|
||
sectionContent?.dispatchEvent(event);
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
// Parse options string into array of {value, label} objects
|
||
static parseOptions(options) {
|
||
if (!options || typeof options !== 'string') {
|
||
return [];
|
||
}
|
||
|
||
return options.split('|').map(opt => {
|
||
const parts = opt.split('=');
|
||
if (parts.length === 2) {
|
||
return { value: parts[0].trim(), label: parts[1].trim() };
|
||
}
|
||
return { value: opt.trim(), label: opt.trim() };
|
||
});
|
||
}
|
||
|
||
// Generate fields for category with 3-per-line layout and smart field detection
|
||
static generateFieldsForCategory(keys, category, config, generateFieldCallback = null) {
|
||
let formHTML = '<div class="domains-divider"></div><div class="config-fields">';
|
||
|
||
keys.forEach((key, index) => {
|
||
const configItem = config[key] || {};
|
||
const value = configItem.value || '';
|
||
const title = configItem.title || this.formatConfigLabel(key);
|
||
const description = configItem.description || '';
|
||
const options = configItem.options || '';
|
||
const fieldId = `config-${key}`;
|
||
|
||
// Add line break every 3 items
|
||
if (index > 0 && index % 3 === 0) {
|
||
formHTML += `</div><div class="config-fields">`;
|
||
}
|
||
|
||
// Use smart field creation if no callback provided, otherwise use callback
|
||
if (generateFieldCallback) {
|
||
formHTML += generateFieldCallback(fieldId, key, value, title, description, options, config);
|
||
} else {
|
||
formHTML += this.createSmartField(fieldId, key, value, title, description, {
|
||
selectOptions: options,
|
||
category: category,
|
||
layout: 'inline',
|
||
config: config
|
||
});
|
||
}
|
||
});
|
||
|
||
formHTML += `</div>`;
|
||
return formHTML;
|
||
}
|
||
|
||
// Generate fields for category WITHOUT the leading divider (for master toggle sections)
|
||
static generateFieldsForCategoryNoDivider(keys, category, config, generateFieldCallback = null) {
|
||
let formHTML = '';
|
||
|
||
keys.forEach((key, index) => {
|
||
const configItem = config[key] || {};
|
||
const value = configItem.value || '';
|
||
const title = configItem.title || this.formatConfigLabel(key);
|
||
const description = configItem.description || '';
|
||
const options = configItem.options || '';
|
||
const fieldId = `config-${key}`;
|
||
|
||
let fieldHTML;
|
||
if (generateFieldCallback) {
|
||
fieldHTML = generateFieldCallback(fieldId, key, value, title, description, options, config);
|
||
} else {
|
||
fieldHTML = this.generateField(fieldId, key, value, title, description, options, config);
|
||
}
|
||
|
||
formHTML += fieldHTML;
|
||
});
|
||
|
||
return formHTML;
|
||
}
|
||
|
||
// Separate categories into regular, advanced, and unused
|
||
static categorizeConfigs(config) {
|
||
const groupedConfigs = this.groupConfigKeys(config);
|
||
const categoryOrder = this.extractCategoryOrder(config);
|
||
|
||
const regularCategories = [];
|
||
const advancedCategories = [];
|
||
const unusedCategories = [];
|
||
|
||
for (const category of categoryOrder) {
|
||
const keys = groupedConfigs[category];
|
||
|
||
if (keys && keys.length > 0 && category !== 'Hidden/Unused Options') {
|
||
// Check if category has advanced items
|
||
const hasAdvanced = keys.some(key => {
|
||
const configItem = config[key] || {};
|
||
return configItem.advanced === true;
|
||
});
|
||
|
||
// Check if category has unused items
|
||
const hasUnused = keys.some(key => {
|
||
const configItem = config[key] || {};
|
||
return configItem.unused === true;
|
||
});
|
||
|
||
// Categorize based on the presence of advanced/unused flags
|
||
if (hasUnused) {
|
||
unusedCategories.push(category);
|
||
} else if (hasAdvanced) {
|
||
advancedCategories.push(category);
|
||
} else {
|
||
regularCategories.push(category);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
groupedConfigs,
|
||
categoryOrder,
|
||
regularCategories,
|
||
advancedCategories,
|
||
unusedCategories
|
||
};
|
||
}
|
||
|
||
// Generate warning notice for requirements page
|
||
static generateRequirementsWarning() {
|
||
return `
|
||
<div class="requirements-warning-section">
|
||
<div class="requirements-warning-header">
|
||
<h3>⚠️ System Requirements Warning</h3>
|
||
<p>Disabling any of the following system requirements may break LibrePortal functionality. Always create a backup before making changes.</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Generate toggle controls HTML for advanced/unused sections
|
||
static generateToggleControls(hasAdvanced = false, hasUnused = false) {
|
||
if (!hasAdvanced && !hasUnused) {
|
||
return ''; // Don't show danger zone if no content
|
||
}
|
||
|
||
let formHTML = `
|
||
<div class="danger-zone-section">
|
||
<div class="danger-zone-header">
|
||
<h3>⚠️ Danger Zone</h3>
|
||
<p>These options are for advanced users and may affect system stability</p>
|
||
</div>
|
||
|
||
<div class="config-toggles">
|
||
`;
|
||
|
||
if (hasAdvanced) {
|
||
formHTML += `
|
||
<div class="toggle-section">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="show-advanced" onchange="ConfigShared.toggleAdvancedSections()">
|
||
<span class="checkbox-custom"></span>
|
||
<div class="toggle-content">
|
||
<span class="checkbox-text">Show Advanced Options</span>
|
||
<span class="checkbox-description">Display advanced configuration settings for experienced users</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (hasUnused) {
|
||
formHTML += `
|
||
<div class="toggle-section">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" id="show-unused" onchange="ConfigShared.toggleUnusedSections()">
|
||
<span class="checkbox-custom"></span>
|
||
<div class="toggle-content">
|
||
<span class="checkbox-text">Show Unused Options</span>
|
||
<span class="checkbox-description">Display deprecated or unused configuration options</span>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
formHTML += `
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return formHTML;
|
||
}
|
||
|
||
// Generate advanced sections HTML
|
||
static async generateAdvancedSections(advancedCategories, groupedConfigs, config, getCategoryDescriptionCallback) {
|
||
if (advancedCategories.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
let formHTML = `
|
||
<div id="advanced-sections" class="advanced-sections" style="display: none;">
|
||
<div class="section-divider">
|
||
<h3>🛠️ Advanced Configuration</h3>
|
||
<p>Advanced settings for experienced users</p>
|
||
</div>
|
||
`;
|
||
|
||
for (const category of advancedCategories) {
|
||
const keys = groupedConfigs[category];
|
||
|
||
if (keys && keys.length > 0) {
|
||
const displayCategory = this.formatCategoryName(category);
|
||
const categoryDescription = getCategoryDescriptionCallback ? await getCategoryDescriptionCallback(category) : '';
|
||
|
||
// Check if this category has a master toggle (any key with master: true)
|
||
const masterKey = keys.find(key => {
|
||
const configItem = config[key] || {};
|
||
return configItem.master === true;
|
||
});
|
||
|
||
if (masterKey) {
|
||
// Dynamic master toggle handling for advanced sections
|
||
const masterValue = config[masterKey]?.value || 'false';
|
||
const isMasterEnabled = masterValue === 'true';
|
||
const masterTitle = config[masterKey]?.title || this.formatConfigLabel(masterKey);
|
||
const masterDescription = config[masterKey]?.description || '';
|
||
const sectionId = `${category.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
||
const toggleId = `${masterKey.toLowerCase()}-toggle`;
|
||
|
||
formHTML += `
|
||
<div class="config-section advanced-section">
|
||
<h3>${displayCategory}</h3>
|
||
<p class="category-description">${categoryDescription}</p>
|
||
<div class="domains-divider"></div>
|
||
<div class="git-master-toggle">
|
||
<div class="field-group">
|
||
<label class="checkbox-label master-toggle">
|
||
<input type="checkbox" id="${toggleId}" name="${masterKey}" value="true" ${isMasterEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSectionVisibility('section-content-${sectionId}', this.checked)">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${masterTitle}
|
||
<span class="tooltip" title="${masterDescription}">ℹ️</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="section-content-${sectionId}" class="git-section-content ${isMasterEnabled ? '' : 'hidden'}">
|
||
<div class="config-fields">
|
||
${this.generateFieldsForCategoryNoDivider(keys.filter(key => key !== masterKey), category, config)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
// Regular advanced section handling (no master toggle)
|
||
formHTML += `
|
||
<div class="config-section advanced-section">
|
||
<h3>${displayCategory}</h3>
|
||
<p class="category-description">${categoryDescription}</p>
|
||
<div class="config-group">
|
||
${this.generateFieldsForCategory(keys, category, config)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
formHTML += `</div>`;
|
||
return formHTML;
|
||
}
|
||
|
||
// Generate unused sections HTML
|
||
static async generateUnusedSections(unusedCategories, groupedConfigs, config, getCategoryDescriptionCallback) {
|
||
if (unusedCategories.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
let formHTML = `
|
||
<div id="unused-sections" class="unused-sections" style="display: none;">
|
||
<div class="section-divider">
|
||
<h3>🗑️ Unused Configuration</h3>
|
||
<p>Deprecated or unused configuration options</p>
|
||
</div>
|
||
`;
|
||
|
||
for (const category of unusedCategories) {
|
||
const keys = groupedConfigs[category];
|
||
|
||
if (keys && keys.length > 0) {
|
||
const displayCategory = this.formatCategoryName(category);
|
||
const categoryDescription = getCategoryDescriptionCallback ? await getCategoryDescriptionCallback(category) : '';
|
||
|
||
// Check if this category has a master toggle (any key with master: true)
|
||
const masterKey = keys.find(key => {
|
||
const configItem = config[key] || {};
|
||
return configItem.master === true;
|
||
});
|
||
|
||
if (masterKey) {
|
||
// Dynamic master toggle handling for unused sections
|
||
const masterValue = config[masterKey]?.value || 'false';
|
||
const isMasterEnabled = masterValue === 'true';
|
||
const masterTitle = config[masterKey]?.title || this.formatConfigLabel(masterKey);
|
||
const masterDescription = config[masterKey]?.description || '';
|
||
const sectionId = `${category.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
|
||
const toggleId = `${masterKey.toLowerCase()}-toggle`;
|
||
|
||
formHTML += `
|
||
<div class="config-section unused-section">
|
||
<h3>${displayCategory}</h3>
|
||
<p class="category-description">${categoryDescription}</p>
|
||
<div class="domains-divider"></div>
|
||
<div class="git-master-toggle">
|
||
<div class="field-group">
|
||
<label class="checkbox-label master-toggle">
|
||
<input type="checkbox" id="${toggleId}" name="${masterKey}" value="true" ${isMasterEnabled ? 'checked' : ''} onchange="ConfigShared.toggleSectionVisibility('section-content-${sectionId}', this.checked)">
|
||
<span class="checkbox-custom"></span>
|
||
<span class="checkbox-text">
|
||
${masterTitle}
|
||
<span class="tooltip" title="${masterDescription}">ℹ️</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div id="section-content-${sectionId}" class="git-section-content ${isMasterEnabled ? '' : 'hidden'}">
|
||
<div class="config-fields">
|
||
${this.generateFieldsForCategoryNoDivider(keys.filter(key => key !== masterKey), category, config)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
// Regular unused section handling (no master toggle)
|
||
formHTML += `
|
||
<div class="config-section unused-section">
|
||
<h3>${displayCategory}</h3>
|
||
<p class="category-description">${categoryDescription}</p>
|
||
<div class="config-group">
|
||
${this.generateFieldsForCategory(keys, category, config)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
formHTML += `</div>`;
|
||
return formHTML;
|
||
}
|
||
|
||
// Toggle advanced sections visibility
|
||
static toggleAdvancedSections() {
|
||
const checkbox = document.getElementById('show-advanced');
|
||
const advancedSections = document.getElementById('advanced-sections');
|
||
|
||
if (advancedSections) {
|
||
advancedSections.style.display = checkbox.checked ? 'block' : 'none';
|
||
}
|
||
}
|
||
|
||
// Toggle unused sections visibility
|
||
static toggleUnusedSections() {
|
||
const checkbox = document.getElementById('show-unused');
|
||
const unusedSections = document.getElementById('unused-sections');
|
||
|
||
if (unusedSections) {
|
||
unusedSections.style.display = checkbox.checked ? 'block' : 'none';
|
||
}
|
||
}
|
||
}
|
||
|
||
// Export for global access
|
||
window.ConfigShared = ConfigShared;
|
||
|
||
// Global toggle change function for checkbox handling
|
||
window.handleToggleChange = function(checkbox, key) {
|
||
//console.log(`Toggle changed: ${key} = ${checkbox.checked}`);
|
||
// This function can be extended to handle specific toggle logic
|
||
// For now, it just logs change
|
||
};
|
||
|
||
// Global flag to prevent multiple config reloads
|
||
window.isReloadingConfig = false;
|
||
|
||
// Global function to handle CFG_INSTALL_MODE change
|
||
window.handleInstallModeChange = function(selectElement) {
|
||
// Prevent multiple simultaneous reloads
|
||
if (window.isReloadingConfig) {
|
||
//console.log('Config reload already in progress, ignoring...');
|
||
return;
|
||
}
|
||
|
||
const installMode = selectElement.value;
|
||
//console.log(`Install mode changed to: ${installMode}`);
|
||
|
||
// Set flag to prevent multiple reloads
|
||
window.isReloadingConfig = true;
|
||
|
||
// Use a longer delay and onchange instead of onblur to avoid the loop
|
||
setTimeout(() => {
|
||
if (window.configManager) {
|
||
// Clear cache to ensure we get fresh data with updated CFG_INSTALL_MODE
|
||
window.configManager.cache.clear();
|
||
//console.log('Cache cleared for fresh config data');
|
||
|
||
// Get the current category from the URL or default to 'general'
|
||
const currentCategory = window.configCategory || 'general';
|
||
//console.log(`Reloading config category: ${currentCategory}`);
|
||
window.configManager.renderConfig(currentCategory).finally(() => {
|
||
// Clear the flag after reload is complete
|
||
setTimeout(() => {
|
||
window.isReloadingConfig = false;
|
||
}, 500); // Extra delay to ensure DOM is fully settled
|
||
});
|
||
} else if (window.configRouter) {
|
||
// Fallback to configRouter if configManager is not available
|
||
const currentCategory = window.configCategory || 'general';
|
||
//console.log(`Using configRouter to reload: ${currentCategory}`);
|
||
window.configRouter.loadConfigComponentManual(currentCategory).finally(() => {
|
||
// Clear the flag after reload is complete
|
||
setTimeout(() => {
|
||
window.isReloadingConfig = false;
|
||
}, 500); // Extra delay to ensure DOM is fully settled
|
||
});
|
||
} else {
|
||
console.warn('Neither configManager nor configRouter available for install mode change');
|
||
window.isReloadingConfig = false;
|
||
}
|
||
}, 500); // Longer delay to avoid conflicts
|
||
};
|
||
|
||
// Global function to initialize git field visibility based on current CFG_INSTALL_MODE
|
||
window.initializeGitFieldVisibility = function() {
|
||
const installModeSelect = document.querySelector('select[name="CFG_INSTALL_MODE"]');
|
||
if (installModeSelect) {
|
||
// Trigger the change handler to set initial visibility
|
||
handleInstallModeChange(installModeSelect);
|
||
}
|
||
};
|
||
|
||
// Global password toggle function for onclick handlers
|
||
window.togglePasswordVisibility = function(fieldId) {
|
||
ConfigShared.togglePasswordVisibility(fieldId);
|
||
};
|