Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
40
status/js/README.md
Normal file
40
status/js/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# DashCaddy Onboarding System
|
||||
|
||||
This directory contains the JavaScript modules for the user onboarding tooltip system.
|
||||
|
||||
## Files
|
||||
|
||||
- **onboarding.js** - Main entry point, initializes the onboarding system
|
||||
- **tour-manager.js** - Orchestrates the onboarding flow and manages tour state
|
||||
- **progress-tracker.js** - Manages persistent storage of user progress
|
||||
- **tooltip-definitions.js** - Defines all tooltip content and positioning
|
||||
- **dns-template-selector.js** - Presents DNS server template options
|
||||
- **theme-adapter.js** - Ensures tooltips match the current dashboard theme
|
||||
|
||||
## Load Order
|
||||
|
||||
The scripts are loaded in the following order (as defined in status/index.html):
|
||||
|
||||
1. progress-tracker.js
|
||||
2. theme-adapter.js
|
||||
3. tooltip-definitions.js
|
||||
4. dns-template-selector.js
|
||||
5. tour-manager.js
|
||||
6. onboarding.js (main initialization)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Driver.js** - Loaded from CDN (https://cdn.jsdelivr.net/npm/driver.js@1.3.1/)
|
||||
- Dashboard CSS variables (for theming)
|
||||
- Browser localStorage API (for progress tracking)
|
||||
|
||||
## Integration
|
||||
|
||||
The onboarding system integrates with:
|
||||
- Dashboard theme system (via CSS variables)
|
||||
- App template selector (for DNS server deployment)
|
||||
- Local storage (for progress persistence)
|
||||
|
||||
## Development
|
||||
|
||||
Each module is wrapped in an IIFE (Immediately Invoked Function Expression) to avoid global namespace pollution. Modules communicate through well-defined interfaces and the window object where necessary.
|
||||
1047
status/js/app-selector.js
Normal file
1047
status/js/app-selector.js
Normal file
File diff suppressed because it is too large
Load Diff
140
status/js/audit-log.js
Normal file
140
status/js/audit-log.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// ========== AUDIT LOG VIEWER ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('audit-modal', `<div id="audit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
||||
<h3>📜 Audit Log</h3>
|
||||
<p class="modal-subtitle">
|
||||
Track all actions performed through the API.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
|
||||
<label class="text-muted-sm">Filter:</label>
|
||||
<select id="audit-filter" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
|
||||
<option value="">All Actions</option>
|
||||
<option value="service">Services</option>
|
||||
<option value="container">Containers</option>
|
||||
<option value="caddy">Caddy</option>
|
||||
<option value="dns">DNS</option>
|
||||
<option value="backup">Backups</option>
|
||||
<option value="config">Config</option>
|
||||
<option value="auth">Auth</option>
|
||||
</select>
|
||||
<button id="audit-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
||||
<span style="flex: 1;"></span>
|
||||
<button id="audit-clear-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--bad-fg); border-color: var(--bad-fg);">🗑️ Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div id="audit-log-container" class="scroll-container">
|
||||
<div class="panel-empty"><span class="brand-spinner"></span> Loading audit log...</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 12px; text-align: center;">
|
||||
<button id="audit-load-more" style="display: none; padding: 6px 16px; font-size: 0.8rem;">Load More</button>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="audit-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('audit-modal');
|
||||
const openBtn = document.getElementById('audit-log-btn');
|
||||
const cancelBtn = document.getElementById('audit-cancel');
|
||||
const refreshBtn = document.getElementById('audit-refresh-btn');
|
||||
const clearBtn = document.getElementById('audit-clear-btn');
|
||||
const filterSelect = document.getElementById('audit-filter');
|
||||
const container = document.getElementById('audit-log-container');
|
||||
const loadMoreBtn = document.getElementById('audit-load-more');
|
||||
let currentOffset = 0;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
async function loadAudit(append) {
|
||||
try {
|
||||
if (!append) {
|
||||
currentOffset = 0;
|
||||
container.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||
}
|
||||
const filter = filterSelect.value;
|
||||
let url = `/api/v1/audit-logs?limit=${PAGE_SIZE}&offset=${currentOffset}`;
|
||||
if (filter) url += `&action=${encodeURIComponent(filter)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const entries = data.success && data.entries ? data.entries : [];
|
||||
|
||||
if (entries.length === 0 && !append) {
|
||||
container.innerHTML = '<div class="panel-empty"><span class="empty-icon">📜</span>No audit log entries yet. Actions will be logged automatically.</div>';
|
||||
loadMoreBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (!append) {
|
||||
html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>';
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const ok = e.outcome === 'success';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(e.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${e.ip || '-'}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${e.action || '-'}</td>`;
|
||||
html += `<td style="padding: 6px;">${e.resource || '-'}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓' : '✗'}</span></td>`;
|
||||
html += '</tr>';
|
||||
if (e.details && Object.keys(e.details).length > 0) {
|
||||
html += `<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${JSON.stringify(e.details, null, 2)}</pre></td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!append) {
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
// Append rows to existing table
|
||||
const table = container.querySelector('table');
|
||||
if (table) table.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
currentOffset += entries.length;
|
||||
loadMoreBtn.style.display = entries.length >= PAGE_SIZE ? '' : 'none';
|
||||
|
||||
// Toggle detail rows on click
|
||||
container.querySelectorAll('.audit-row').forEach(row => {
|
||||
if (row.dataset.wired) return;
|
||||
row.dataset.wired = 'true';
|
||||
row.addEventListener('click', () => {
|
||||
const detail = row.nextElementSibling;
|
||||
if (detail && detail.classList.contains('audit-detail')) {
|
||||
detail.style.display = detail.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal?.classList.add('show');
|
||||
loadAudit(false);
|
||||
});
|
||||
wireModal(modal, cancelBtn);
|
||||
refreshBtn?.addEventListener('click', () => loadAudit(false));
|
||||
filterSelect?.addEventListener('change', () => loadAudit(false));
|
||||
loadMoreBtn?.addEventListener('click', () => loadAudit(true));
|
||||
|
||||
clearBtn?.addEventListener('click', async () => {
|
||||
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/audit-logs', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) loadAudit(false);
|
||||
else showNotification('Error: ' + (data.error || 'Clear failed'), 'error');
|
||||
} catch (e) {
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
})();
|
||||
421
status/js/backup-restore.js
Normal file
421
status/js/backup-restore.js
Normal file
@@ -0,0 +1,421 @@
|
||||
// ========== BACKUP/RESTORE (Enhanced) ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('backup-modal', `<div id="backup-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>💾 Backup & Restore</h3>
|
||||
<p class="modal-subtitle">
|
||||
Manual and automated backups for your DashCaddy configuration.
|
||||
</p>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" data-panel="backup-manual">Manual</button>
|
||||
<button class="panel-tab" data-panel="backup-automated">Automated</button>
|
||||
<button class="panel-tab" data-panel="backup-history-tab">History</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Manual (existing export/import) -->
|
||||
<div id="backup-manual" class="panel-section active">
|
||||
<!-- Export Section -->
|
||||
<div style="margin-bottom: 20px; padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #27ae60 15%, transparent), color-mix(in srgb, #2ecc71 10%, transparent)); border-radius: 10px; border: 1px solid #27ae60;">
|
||||
<h4 style="margin: 0 0 8px; color: #2ecc71; font-size: 0.95rem;">📤 Export Backup</h4>
|
||||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
||||
Download all your settings: services, Caddyfile, DNS credentials, notifications.
|
||||
</p>
|
||||
<button id="backup-export-btn" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||||
⬇️ Download Backup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Import Section -->
|
||||
<div style="padding: 16px; background: linear-gradient(135deg, color-mix(in srgb, #3498db 15%, transparent), color-mix(in srgb, #2980b9 10%, transparent)); border-radius: 10px; border: 1px solid #3498db;">
|
||||
<h4 style="margin: 0 0 8px; color: #3498db; font-size: 0.95rem;">📥 Restore Backup</h4>
|
||||
<p style="font-size: 0.8rem; color: var(--muted); margin: 0 0 12px;">
|
||||
Upload a backup file to restore your configuration.
|
||||
</p>
|
||||
<input type="file" id="backup-file-input" accept=".json" style="display: none;" />
|
||||
<button id="backup-select-file" style="width: 100%; padding: 10px; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||||
📁 Select Backup File
|
||||
</button>
|
||||
<div id="backup-file-name" style="display: none; margin-top: 8px; padding: 8px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Section (shown after file selected) -->
|
||||
<div id="backup-preview" style="display: none; margin-top: 16px; padding: 16px; background: var(--card-base); border-radius: 10px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 12px; font-size: 0.9rem;">📋 Backup Contents</h4>
|
||||
<div id="backup-preview-content" style="font-size: 0.85rem;"></div>
|
||||
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<label class="checkbox-label" style="font-size: 0.85rem;">
|
||||
<input type="checkbox" id="backup-reload-caddy" checked />
|
||||
Reload Caddy after restore
|
||||
</label>
|
||||
</div>
|
||||
<button id="backup-do-restore-btn" style="width: 100%; margin-top: 12px; padding: 10px; background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer;">
|
||||
⚡ Restore Configuration
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Result Message -->
|
||||
<div id="backup-result" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Automated Backups -->
|
||||
<div id="backup-automated" class="panel-section">
|
||||
<div id="backup-schedule-container">
|
||||
<div class="panel-empty">
|
||||
<span class="empty-icon">⏰</span>
|
||||
<span class="brand-spinner"></span> Loading backup schedule...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Backup History -->
|
||||
<div id="backup-history-tab" class="panel-section">
|
||||
<div id="backup-history-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<div class="panel-empty">
|
||||
<span class="empty-icon">📋</span>
|
||||
<span class="brand-spinner"></span> Loading backup history...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="backup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('backup-modal');
|
||||
const openBtn = document.getElementById('backup-restore-btn');
|
||||
const cancelBtn = document.getElementById('backup-cancel');
|
||||
const exportBtn = document.getElementById('backup-export-btn');
|
||||
const selectFileBtn = document.getElementById('backup-select-file');
|
||||
const fileInput = document.getElementById('backup-file-input');
|
||||
const fileNameDiv = document.getElementById('backup-file-name');
|
||||
const previewDiv = document.getElementById('backup-preview');
|
||||
const previewContent = document.getElementById('backup-preview-content');
|
||||
const restoreBtn = document.getElementById('backup-do-restore-btn');
|
||||
const resultDiv = document.getElementById('backup-result');
|
||||
const scheduleContainer = document.getElementById('backup-schedule-container');
|
||||
const historyContainer = document.getElementById('backup-history-container');
|
||||
|
||||
let selectedBackup = null;
|
||||
|
||||
// Open modal
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
if (resultDiv) resultDiv.style.display = 'none';
|
||||
if (previewDiv) previewDiv.style.display = 'none';
|
||||
if (fileNameDiv) fileNameDiv.style.display = 'none';
|
||||
selectedBackup = null;
|
||||
});
|
||||
|
||||
wireModal(modal, cancelBtn);
|
||||
|
||||
// Export backup
|
||||
exportBtn?.addEventListener('click', async () => {
|
||||
exportBtn.disabled = true;
|
||||
exportBtn.innerHTML = '<span class="brand-spinner"></span> Exporting...';
|
||||
try {
|
||||
const response = await fetch('/api/v1/backup/export');
|
||||
const data = await response.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
resultDiv.innerHTML = '✅ Backup downloaded successfully!';
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = `❌ Export failed: ${e.message}`;
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||||
}
|
||||
exportBtn.disabled = false;
|
||||
exportBtn.innerHTML = '⬇️ Download Backup';
|
||||
});
|
||||
|
||||
// Select file button
|
||||
selectFileBtn?.addEventListener('click', () => fileInput.click());
|
||||
|
||||
// File selected
|
||||
fileInput?.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
fileNameDiv.textContent = `📄 ${file.name}`;
|
||||
fileNameDiv.style.display = 'block';
|
||||
resultDiv.style.display = 'none';
|
||||
try {
|
||||
const text = await file.text();
|
||||
const backup = JSON.parse(text);
|
||||
const response = await secureFetch('/api/v1/backup/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(backup)
|
||||
});
|
||||
const preview = await response.json();
|
||||
if (preview.success) {
|
||||
selectedBackup = backup;
|
||||
let html = `<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">
|
||||
Exported: ${new Date(backup.exportedAt).toLocaleString()}</div>`;
|
||||
html += '<div style="display: flex; flex-wrap: wrap; gap: 6px;">';
|
||||
for (const [key, info] of Object.entries(preview.preview.files)) {
|
||||
const icon = info.action === 'create' ? '🆕' : '📝';
|
||||
html += `<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">${icon} ${info.description}</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
if (preview.preview.serviceCount) {
|
||||
html += `<div style="margin-top: 8px; font-size: 0.8rem; color: var(--accent);">${preview.preview.serviceCount} services in backup</div>`;
|
||||
}
|
||||
previewContent.innerHTML = html;
|
||||
previewDiv.style.display = 'block';
|
||||
} else {
|
||||
resultDiv.innerHTML = `⚠️ Invalid backup file: ${preview.error}`;
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||||
resultDiv.style.border = '1px solid #f39c12';
|
||||
previewDiv.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = `❌ Could not read file: ${e.message}`;
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||||
previewDiv.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Restore from file backup
|
||||
restoreBtn?.addEventListener('click', async () => {
|
||||
if (!selectedBackup) return;
|
||||
if (!confirm('This will overwrite your current configuration. Continue?')) return;
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.innerHTML = '<span class="brand-spinner"></span> Restoring...';
|
||||
try {
|
||||
const reloadCaddy = document.getElementById('backup-reload-caddy')?.checked ?? true;
|
||||
const response = await secureFetch('/api/v1/backup/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backup: selectedBackup, options: { reloadCaddy } })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
let msg = `✅ ${data.message}`;
|
||||
if (data.results.caddyReloaded) msg += '<br><small style="color: var(--muted);">Caddy configuration reloaded</small>';
|
||||
resultDiv.innerHTML = msg;
|
||||
resultDiv.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||||
resultDiv.style.border = '1px solid var(--ok-fg)';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
resultDiv.innerHTML = `⚠️ ${data.message}`;
|
||||
if (data.results?.errors?.length > 0) {
|
||||
resultDiv.innerHTML += '<br><small>' + data.results.errors.map(e => `${e.file}: ${e.error}`).join(', ') + '</small>';
|
||||
}
|
||||
resultDiv.style.background = 'color-mix(in srgb, #f39c12 15%, transparent)';
|
||||
resultDiv.style.border = '1px solid #f39c12';
|
||||
}
|
||||
resultDiv.style.display = 'block';
|
||||
} catch (e) {
|
||||
resultDiv.innerHTML = `❌ Restore failed: ${e.message}`;
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||||
resultDiv.style.border = '1px solid var(--bad-fg)';
|
||||
}
|
||||
restoreBtn.disabled = false;
|
||||
restoreBtn.innerHTML = '⚡ Restore Configuration';
|
||||
});
|
||||
|
||||
// === Automated Backups Tab ===
|
||||
async function loadBackupSchedule() {
|
||||
if (!scheduleContainer) return;
|
||||
try {
|
||||
const res = await fetch('/api/v1/backups/config');
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to load config');
|
||||
const cfg = data.config?.backups || {};
|
||||
const autoKey = Object.keys(cfg)[0];
|
||||
const auto = autoKey ? cfg[autoKey] : null;
|
||||
|
||||
let html = `<div style="padding: 16px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 10px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); margin-bottom: 16px;">`;
|
||||
html += `<h4 style="margin: 0 0 12px; color: var(--accent); font-size: 0.9rem;">⏰ Backup Schedule</h4>`;
|
||||
html += `<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">`;
|
||||
html += `<div><label style="font-size: 0.8rem; color: var(--muted);">Schedule:</label>
|
||||
<select id="backup-schedule-select" style="width: 100%;">
|
||||
<option value="disabled" ${!auto?.enabled ? 'selected' : ''}>Disabled</option>
|
||||
<option value="hourly" ${auto?.schedule === 'hourly' ? 'selected' : ''}>Hourly</option>
|
||||
<option value="daily" ${auto?.schedule === 'daily' ? 'selected' : ''}>Daily</option>
|
||||
<option value="weekly" ${auto?.schedule === 'weekly' ? 'selected' : ''}>Weekly</option>
|
||||
<option value="monthly" ${auto?.schedule === 'monthly' ? 'selected' : ''}>Monthly</option>
|
||||
</select></div>`;
|
||||
html += `<div><label style="font-size: 0.8rem; color: var(--muted);">Keep last:</label>
|
||||
<select id="backup-retention-select" style="width: 100%;">
|
||||
<option value="3" ${auto?.retention?.keep === 3 ? 'selected' : ''}>3 backups</option>
|
||||
<option value="5" ${!auto?.retention || auto?.retention?.keep === 5 ? 'selected' : ''}>5 backups</option>
|
||||
<option value="10" ${auto?.retention?.keep === 10 ? 'selected' : ''}>10 backups</option>
|
||||
<option value="30" ${auto?.retention?.keep === 30 ? 'selected' : ''}>30 backups</option>
|
||||
</select></div>`;
|
||||
html += `</div>`;
|
||||
html += `<div style="margin-top: 12px;">
|
||||
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer;">
|
||||
<input type="checkbox" id="backup-encrypt-toggle" ${auto?.encrypt !== false ? 'checked' : ''} />
|
||||
Encrypt backups
|
||||
</label></div>`;
|
||||
html += `<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||||
<button id="backup-save-schedule" style="padding: 8px 16px; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 6px; cursor: pointer; font-weight: 500;">Save Schedule</button>
|
||||
<button id="backup-run-now" style="padding: 8px 16px; border-radius: 6px; cursor: pointer;">▶️ Run Backup Now</button>
|
||||
</div>`;
|
||||
html += `</div>`;
|
||||
html += `<div id="backup-schedule-result" style="display: none; margin-top: 12px; padding: 10px; border-radius: 8px; font-size: 0.85rem;"></div>`;
|
||||
scheduleContainer.innerHTML = html;
|
||||
|
||||
document.getElementById('backup-save-schedule')?.addEventListener('click', saveSchedule);
|
||||
document.getElementById('backup-run-now')?.addEventListener('click', runBackupNow);
|
||||
} catch (e) {
|
||||
scheduleContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load schedule: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedule() {
|
||||
const schedule = document.getElementById('backup-schedule-select')?.value;
|
||||
const retention = parseInt(document.getElementById('backup-retention-select')?.value) || 5;
|
||||
const encrypt = document.getElementById('backup-encrypt-toggle')?.checked ?? true;
|
||||
const resultEl = document.getElementById('backup-schedule-result');
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/backups/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
backups: {
|
||||
auto: {
|
||||
enabled: schedule !== 'disabled',
|
||||
schedule: schedule === 'disabled' ? 'daily' : schedule,
|
||||
include: ['all'],
|
||||
encrypt,
|
||||
verify: true,
|
||||
retention: { keep: retention },
|
||||
destinations: [{ type: 'local' }]
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = data.success ? '✅ Schedule saved' : `⚠️ ${data.error}`;
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.style.background = data.success ? 'color-mix(in srgb, var(--ok-fg) 15%, transparent)' : 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||||
resultEl.style.border = data.success ? '1px solid var(--ok-fg)' : '1px solid var(--bad-fg)';
|
||||
setTimeout(() => { if (resultEl) resultEl.style.display = 'none'; }, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = `❌ ${e.message}`;
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||||
resultEl.style.border = '1px solid var(--bad-fg)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runBackupNow() {
|
||||
const btn = document.getElementById('backup-run-now');
|
||||
const resultEl = document.getElementById('backup-schedule-result');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="brand-spinner"></span> Running...'; }
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/backups/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ include: ['all'], destinations: [{ type: 'local' }] })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (resultEl) {
|
||||
if (data.success) {
|
||||
const sizeMB = data.backup?.size ? (data.backup.size / 1024 / 1024).toFixed(2) : '?';
|
||||
resultEl.innerHTML = `✅ Backup complete (${sizeMB} MB)`;
|
||||
resultEl.style.background = 'color-mix(in srgb, var(--ok-fg) 15%, transparent)';
|
||||
resultEl.style.border = '1px solid var(--ok-fg)';
|
||||
} else {
|
||||
resultEl.innerHTML = `⚠️ ${data.error}`;
|
||||
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||||
resultEl.style.border = '1px solid var(--bad-fg)';
|
||||
}
|
||||
resultEl.style.display = 'block';
|
||||
}
|
||||
loadBackupHistory();
|
||||
} catch (e) {
|
||||
if (resultEl) {
|
||||
resultEl.innerHTML = `❌ ${e.message}`;
|
||||
resultEl.style.display = 'block';
|
||||
resultEl.style.background = 'color-mix(in srgb, var(--bad-fg) 15%, transparent)';
|
||||
resultEl.style.border = '1px solid var(--bad-fg)';
|
||||
}
|
||||
}
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '▶️ Run Backup Now'; }
|
||||
}
|
||||
|
||||
// === Backup History Tab ===
|
||||
async function loadBackupHistory() {
|
||||
if (!historyContainer) return;
|
||||
historyContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||
try {
|
||||
const res = await fetch('/api/v1/backups/history?limit=50');
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.history?.length) {
|
||||
historyContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📋</span> No backup history yet</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 6px;">';
|
||||
for (const bk of data.history) {
|
||||
const statusColor = bk.status === 'success' ? '#2ecc71' : '#e74c3c';
|
||||
const sizeMB = bk.size ? (bk.size / 1024 / 1024).toFixed(2) : '?';
|
||||
html += `<div style="padding: 10px 12px; background: var(--card-base); border-radius: 6px; border: 1px solid var(--border); font-size: 0.85rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||
<span style="font-weight: 500;">${bk.name || 'backup'}</span>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span class="status-badge ${bk.status === 'success' ? 'success' : 'down'}">${bk.status}</span>
|
||||
${bk.status === 'success' ? `<button onclick="window.__restoreServerBackup('${bk.id}')" style="padding: 3px 8px; font-size: 0.75rem;">Restore</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted);">
|
||||
${new Date(bk.timestamp).toLocaleString()} | ${sizeMB} MB | ${bk.duration ? (bk.duration / 1000).toFixed(1) + 's' : '--'}
|
||||
${bk.encrypted ? ' | 🔒' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
historyContainer.innerHTML = html;
|
||||
} catch (e) {
|
||||
historyContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
window.__restoreServerBackup = async function(backupId) {
|
||||
if (!confirm('Restore from this server backup? This will overwrite current configuration.')) return;
|
||||
try {
|
||||
const res = await secureFetch(`/api/v1/backups/restore/${backupId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ restoreServices: true, restoreConfig: true })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showNotification('Restore completed successfully!', 'success');
|
||||
location.reload();
|
||||
} else {
|
||||
showNotification('Restore failed: ' + (data.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (e) { showNotification('Restore error: ' + e.message, 'error'); }
|
||||
};
|
||||
|
||||
// Lazy-load tabs
|
||||
document.querySelector('[data-panel="backup-automated"]')?.addEventListener('click', loadBackupSchedule);
|
||||
document.querySelector('[data-panel="backup-history-tab"]')?.addEventListener('click', loadBackupHistory);
|
||||
})();
|
||||
115
status/js/card-badges.js
Normal file
115
status/js/card-badges.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ========== CARD HEALTH & UPDATE BADGES ==========
|
||||
(function() {
|
||||
// Fetch health data and update card UI
|
||||
async function refreshCardHealth() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/health-checks/status');
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.status) return;
|
||||
|
||||
for (const [serviceId, svc] of Object.entries(data.status)) {
|
||||
const uptimeEl = document.getElementById('uptime-' + serviceId);
|
||||
const barEl = document.getElementById('uptime-bar-' + serviceId);
|
||||
if (!uptimeEl) continue;
|
||||
|
||||
const uptime24 = svc.uptime?.['24h'];
|
||||
if (uptime24 !== undefined && uptime24 !== null) {
|
||||
const pct = uptime24.toFixed(1);
|
||||
uptimeEl.textContent = `${pct}% uptime`;
|
||||
// Color class
|
||||
uptimeEl.className = 'uptime-chip';
|
||||
if (uptime24 >= 99.9) uptimeEl.classList.add('excellent');
|
||||
else if (uptime24 >= 99) uptimeEl.classList.add('good');
|
||||
else if (uptime24 >= 95) uptimeEl.classList.add('degraded');
|
||||
else uptimeEl.classList.add('poor');
|
||||
|
||||
if (barEl) barEl.style.width = pct + '%';
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* Health check API unavailable — uptime chips stay hidden */
|
||||
console.warn('[Card Badges] Health check API unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
// Track dismissed updates (per session)
|
||||
let dismissedUpdates;
|
||||
try {
|
||||
dismissedUpdates = new Set(JSON.parse(safeSessionGet('dismissed-updates') || '[]'));
|
||||
} catch (_) {
|
||||
/* Session storage unavailable */
|
||||
dismissedUpdates = new Set();
|
||||
}
|
||||
|
||||
// Fetch update data and show badges
|
||||
async function refreshCardUpdates() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/updates/available');
|
||||
const data = await res.json();
|
||||
if (!data.success) return;
|
||||
|
||||
// Clear all update badges first
|
||||
document.querySelectorAll('.update-available-badge').forEach(el => el.classList.remove('visible'));
|
||||
|
||||
if (!data.updates?.length) return;
|
||||
|
||||
for (const upd of data.updates) {
|
||||
// Try to match by container name to service id
|
||||
const apps = window.APPS || [];
|
||||
for (const app of apps) {
|
||||
if (app.containerId === upd.containerId || app.id === upd.containerName || app.name === upd.containerName) {
|
||||
// Skip dismissed updates
|
||||
if (dismissedUpdates.has(app.id)) break;
|
||||
const badge = document.getElementById('update-badge-' + app.id);
|
||||
if (badge) {
|
||||
badge.classList.add('visible');
|
||||
badge.title = `Image digest changed. Click to dismiss if already up to date.\n${upd.imageName || ''}`;
|
||||
badge.style.cursor = 'pointer';
|
||||
badge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
badge.classList.remove('visible');
|
||||
dismissedUpdates.add(app.id);
|
||||
safeSessionSet('dismissed-updates', JSON.stringify([...dismissedUpdates]));
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* Updates API unavailable — badges stay hidden */
|
||||
console.warn('[Card Badges] Updates API unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
// Run health and update checks after main refresh, and periodically
|
||||
function scheduleCardEnhancements() {
|
||||
// Initial load (delayed to not compete with main probe checks)
|
||||
setTimeout(() => {
|
||||
refreshCardHealth();
|
||||
refreshCardUpdates();
|
||||
}, 5000);
|
||||
|
||||
// Periodic refresh every 60 seconds
|
||||
setInterval(() => {
|
||||
refreshCardHealth();
|
||||
refreshCardUpdates();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
// Hook into main refresh cycle
|
||||
const origRefreshAll = window.refreshAll;
|
||||
if (origRefreshAll) {
|
||||
window.refreshAll = async function() {
|
||||
try {
|
||||
await origRefreshAll();
|
||||
// Refresh health data after main status check
|
||||
setTimeout(refreshCardHealth, 1000);
|
||||
} catch (e) {
|
||||
console.warn('[Card Badges] Error in refreshAll hook:', e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scheduleCardEnhancements();
|
||||
})();
|
||||
330
status/js/clock.js
Normal file
330
status/js/clock.js
Normal file
@@ -0,0 +1,330 @@
|
||||
// ========== DIGITAL CLOCK WIDGET ==========
|
||||
(function() {
|
||||
const widget = document.getElementById('clock-widget');
|
||||
const render = document.getElementById('clock-render');
|
||||
if (!widget || !render) return;
|
||||
|
||||
const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
||||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||
const ROMAN = ['XII','I','II','III','IV','V','VI','VII','VIII','IX','X','XI'];
|
||||
|
||||
let currentStyle = safeGet('clock-style') || 'default';
|
||||
let lastChimeHour = -1;
|
||||
let chimePlaying = false;
|
||||
let prevFlipDigits = '';
|
||||
|
||||
// ===== CHIMES =====
|
||||
function playChimes(count) {
|
||||
if (chimePlaying) return;
|
||||
if (safeGet('clock-chimes') !== 'true') return;
|
||||
chimePlaying = true;
|
||||
const vol = parseInt(safeGet('clock-chime-volume') || '50', 10) / 100;
|
||||
let i = 0;
|
||||
function strike() {
|
||||
if (i >= count) { chimePlaying = false; return; }
|
||||
const bell = new Audio('/assets/sounds/church-bell.mp3');
|
||||
bell.volume = vol;
|
||||
bell.play().catch(() => {});
|
||||
i++;
|
||||
if (i < count) setTimeout(strike, 2500);
|
||||
else setTimeout(() => { chimePlaying = false; }, 2500);
|
||||
}
|
||||
strike();
|
||||
}
|
||||
|
||||
// ===== DATE STRING =====
|
||||
function dateStr(now) {
|
||||
return DAYS[now.getDay()] + ', ' + MONTHS[now.getMonth()] + ' ' + now.getDate() + ', ' + now.getFullYear();
|
||||
}
|
||||
|
||||
// ===== STYLE RENDERERS =====
|
||||
|
||||
function renderDefault(now) {
|
||||
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
||||
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
||||
const h = h24 % 12 || 12;
|
||||
render.innerHTML =
|
||||
`<div class="clock-time">${h}:${String(m).padStart(2,'0')}<span class="clock-seconds">:${String(s).padStart(2,'0')}</span><span class="clock-ampm">${ampm}</span></div>` +
|
||||
`<div class="clock-date">${dateStr(now)}</div>`;
|
||||
}
|
||||
|
||||
function renderLcd(now, cls) {
|
||||
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
||||
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
||||
const h = h24 % 12 || 12;
|
||||
render.innerHTML =
|
||||
`<div class="clock-time">${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}<span class="clock-seconds">:${String(s).padStart(2,'0')}</span><span class="clock-ampm">${ampm}</span></div>` +
|
||||
`<div class="clock-date">${dateStr(now)}</div>`;
|
||||
}
|
||||
|
||||
function renderFlip(now) {
|
||||
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
||||
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
||||
const h = h24 % 12 || 12;
|
||||
const digits = String(h).padStart(2,' ') + String(m).padStart(2,'0') + String(s).padStart(2,'0');
|
||||
|
||||
let html = '<div class="flip-clock-row">';
|
||||
// Hours
|
||||
html += flipCard(digits[0], 0);
|
||||
html += flipCard(digits[1], 1);
|
||||
html += '<span class="flip-colon">:</span>';
|
||||
// Minutes
|
||||
html += flipCard(digits[2], 2);
|
||||
html += flipCard(digits[3], 3);
|
||||
html += '<span class="flip-colon">:</span>';
|
||||
// Seconds
|
||||
html += flipCard(digits[4], 4);
|
||||
html += flipCard(digits[5], 5);
|
||||
html += `<span class="flip-ampm">${ampm}</span>`;
|
||||
html += '</div>';
|
||||
html += `<div class="clock-date">${dateStr(now)}</div>`;
|
||||
render.innerHTML = html;
|
||||
|
||||
// Trigger flip animation on changed digits
|
||||
if (prevFlipDigits) {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (digits[i] !== prevFlipDigits[i]) {
|
||||
const card = render.querySelector(`.flip-card[data-idx="${i}"]`);
|
||||
if (card) card.classList.add('flipping');
|
||||
}
|
||||
}
|
||||
}
|
||||
prevFlipDigits = digits;
|
||||
}
|
||||
|
||||
function flipCard(digit, idx) {
|
||||
const d = digit === ' ' ? '' : digit;
|
||||
return `<div class="flip-card" data-idx="${idx}"><div class="flip-top">${d}</div><div class="flip-bottom">${d}</div></div>`;
|
||||
}
|
||||
|
||||
function renderBinary(now) {
|
||||
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
||||
const h = h24 % 12 || 12;
|
||||
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
||||
// 6 columns: H tens, H ones, M tens, M ones, S tens, S ones
|
||||
const cols = [
|
||||
Math.floor(h / 10), h % 10,
|
||||
Math.floor(m / 10), m % 10,
|
||||
Math.floor(s / 10), s % 10
|
||||
];
|
||||
|
||||
let html = '<div class="binary-clock">';
|
||||
// Labels
|
||||
html += '<div class="binary-labels"><span>H</span><span>H</span><span>M</span><span>M</span><span>S</span><span>S</span></div>';
|
||||
// 4 rows for bits 8,4,2,1
|
||||
for (let bit = 3; bit >= 0; bit--) {
|
||||
html += '<div class="binary-row">';
|
||||
for (let col = 0; col < 6; col++) {
|
||||
const on = (cols[col] >> bit) & 1;
|
||||
html += `<div class="binary-dot ${on ? 'on' : ''}"></div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
// Value row
|
||||
html += '<div class="binary-values">';
|
||||
for (let col = 0; col < 6; col++) {
|
||||
html += `<span>${cols[col]}</span>`;
|
||||
}
|
||||
html += '</div>';
|
||||
html += `<div class="binary-ampm">${ampm}</div>`;
|
||||
html += '</div>';
|
||||
html += `<div class="clock-date">${dateStr(now)}</div>`;
|
||||
render.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderAnalog(now, useRoman) {
|
||||
const h = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
||||
const size = 120;
|
||||
const cx = size / 2, cy = size / 2;
|
||||
|
||||
// Angles
|
||||
const sAngle = (s / 60) * 360 - 90;
|
||||
const mAngle = ((m + s / 60) / 60) * 360 - 90;
|
||||
const hAngle = (((h % 12) + m / 60) / 12) * 360 - 90;
|
||||
|
||||
// Number labels
|
||||
let labels = '';
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
const angle = (i / 12) * 2 * Math.PI - Math.PI / 2;
|
||||
const r = 47;
|
||||
const x = cx + r * Math.cos(angle);
|
||||
const y = cy + r * Math.sin(angle);
|
||||
const label = useRoman ? ROMAN[i % 12] : i;
|
||||
const fs = useRoman ? '7' : '9';
|
||||
labels += `<text x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central" fill="var(--fg)" font-size="${fs}" font-weight="600" font-family="'Sami Grotesk',sans-serif">${label}</text>`;
|
||||
}
|
||||
|
||||
// Tick marks
|
||||
let ticks = '';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const angle = (i / 60) * 2 * Math.PI - Math.PI / 2;
|
||||
const outer = 56;
|
||||
const inner = i % 5 === 0 ? 52 : 54;
|
||||
const x1 = cx + inner * Math.cos(angle);
|
||||
const y1 = cy + inner * Math.sin(angle);
|
||||
const x2 = cx + outer * Math.cos(angle);
|
||||
const y2 = cy + outer * Math.sin(angle);
|
||||
const w = i % 5 === 0 ? 1.5 : 0.5;
|
||||
ticks += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="var(--muted)" stroke-width="${w}" stroke-linecap="round"/>`;
|
||||
}
|
||||
|
||||
const svg = `<svg class="analog-clock-svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
|
||||
<circle cx="${cx}" cy="${cy}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
${ticks}
|
||||
${labels}
|
||||
<line x1="${cx}" y1="${cy}" x2="${cx + 28 * Math.cos(hAngle * Math.PI / 180)}" y2="${cy + 28 * Math.sin(hAngle * Math.PI / 180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="${cx}" y1="${cy}" x2="${cx + 38 * Math.cos(mAngle * Math.PI / 180)}" y2="${cy + 38 * Math.sin(mAngle * Math.PI / 180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${cx}" y1="${cy}" x2="${cx + 42 * Math.cos(sAngle * Math.PI / 180)}" y2="${cy + 42 * Math.sin(sAngle * Math.PI / 180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${cx}" cy="${cy}" r="3" fill="var(--fg)"/>
|
||||
</svg>`;
|
||||
|
||||
const ampm = now.getHours() >= 12 ? 'PM' : 'AM';
|
||||
render.innerHTML = `<div class="analog-clock-wrap">${svg}<div class="analog-info"><span class="analog-digital">${(now.getHours() % 12 || 12)}:${String(m).padStart(2,'0')} ${ampm}</span><span class="analog-date-sm">${dateStr(now)}</span></div></div>`;
|
||||
}
|
||||
|
||||
// ===== MAIN TICK =====
|
||||
function tick() {
|
||||
const now = new Date();
|
||||
const h = now.getHours() % 12 || 12;
|
||||
const m = now.getMinutes();
|
||||
const s = now.getSeconds();
|
||||
|
||||
// Set style class on widget
|
||||
widget.className = 'clock-widget' + (currentStyle !== 'default' ? ' ' + currentStyle : '');
|
||||
|
||||
switch (currentStyle) {
|
||||
case 'lcd': renderLcd(now); break;
|
||||
case 'lcd-blue': renderLcd(now); break;
|
||||
case 'lcd-amber': renderLcd(now); break;
|
||||
case 'lcd-retro': renderLcd(now); break;
|
||||
case 'lcd-taxi': renderLcd(now); break;
|
||||
case 'flip': renderFlip(now); break;
|
||||
case 'binary': renderBinary(now); break;
|
||||
case 'analog': renderAnalog(now, false); break;
|
||||
case 'roman': renderAnalog(now, true); break;
|
||||
default: renderDefault(now);
|
||||
}
|
||||
|
||||
// Hourly chimes
|
||||
if (m === 0 && s === 0 && h !== lastChimeHour) {
|
||||
lastChimeHour = h;
|
||||
playChimes(h);
|
||||
}
|
||||
if (m !== 0) lastChimeHour = -1;
|
||||
}
|
||||
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
|
||||
// ===== SETTINGS MODAL =====
|
||||
const STYLES = [
|
||||
{ id: 'default', label: 'Default', icon: '🕐' },
|
||||
{ id: 'lcd', label: 'LCD Green', icon: '💚' },
|
||||
{ id: 'lcd-blue', label: 'LCD Blue', icon: '💙' },
|
||||
{ id: 'lcd-amber', label: 'LCD Amber', icon: '🟠' },
|
||||
{ id: 'lcd-retro', label: 'LCD Retro', icon: '🟩' },
|
||||
{ id: 'lcd-taxi', label: 'LCD Taxi', icon: '🟡' },
|
||||
{ id: 'flip', label: 'Flip Clock', icon: '📟' },
|
||||
{ id: 'binary', label: 'Binary', icon: '💻' },
|
||||
{ id: 'analog', label: 'Analog', icon: '⏰' },
|
||||
{ id: 'roman', label: 'Roman', icon: '🏛️' },
|
||||
];
|
||||
|
||||
let styleOptionsHtml = '<div class="clock-style-grid">';
|
||||
STYLES.forEach(s => {
|
||||
styleOptionsHtml += `<label class="clock-style-option">
|
||||
<input type="radio" name="clock-style-radio" value="${s.id}">
|
||||
<span class="clock-style-card"><span class="clock-style-icon">${s.icon}</span><span class="clock-style-label">${s.label}</span></span>
|
||||
</label>`;
|
||||
});
|
||||
styleOptionsHtml += '</div>';
|
||||
|
||||
injectModal('clock-settings-modal', `<div id="clock-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="max-width: 420px;">
|
||||
<h3>Clock Settings</h3>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
|
||||
${styleOptionsHtml}
|
||||
</div>
|
||||
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
|
||||
<input type="checkbox" id="clock-chimes-toggle"> Hourly church bell chimes
|
||||
</label>
|
||||
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px; margin-left: 24px;">
|
||||
Strikes the number of the hour (e.g., 3 bells at 3:00)
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom: 16px;" id="clock-volume-section">
|
||||
<label style="display: block; margin-bottom: 6px; font-weight: 600; color: var(--fg);">Volume</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 0.85rem;">🔈</span>
|
||||
<input type="range" id="clock-chime-volume" min="0" max="100" value="50" style="flex: 1; accent-color: var(--ok-fg);">
|
||||
<span style="font-size: 0.85rem;">🔊</span>
|
||||
<button id="clock-chime-test" style="padding: 4px 10px; font-size: 0.78rem; border-radius: 4px; cursor: pointer;">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-modal-buttons">
|
||||
<button id="clock-settings-cancel">Cancel</button>
|
||||
<button id="clock-settings-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('clock-settings-modal');
|
||||
const chimesToggle = document.getElementById('clock-chimes-toggle');
|
||||
const volumeSlider = document.getElementById('clock-chime-volume');
|
||||
const volumeSection = document.getElementById('clock-volume-section');
|
||||
|
||||
function loadSettings() {
|
||||
const style = safeGet('clock-style') || 'default';
|
||||
const radio = modal.querySelector(`input[value="${style}"]`);
|
||||
if (radio) radio.checked = true;
|
||||
chimesToggle.checked = safeGet('clock-chimes') === 'true';
|
||||
volumeSlider.value = safeGet('clock-chime-volume') || '50';
|
||||
volumeSection.style.opacity = chimesToggle.checked ? '1' : '0.4';
|
||||
}
|
||||
|
||||
chimesToggle?.addEventListener('change', () => {
|
||||
volumeSection.style.opacity = chimesToggle.checked ? '1' : '0.4';
|
||||
});
|
||||
|
||||
document.getElementById('clock-settings')?.addEventListener('click', () => {
|
||||
loadSettings();
|
||||
modal.classList.add('show');
|
||||
});
|
||||
|
||||
document.getElementById('clock-chime-test')?.addEventListener('click', () => {
|
||||
const vol = parseInt(volumeSlider.value, 10) / 100;
|
||||
const bell = new Audio('/assets/sounds/church-bell.mp3');
|
||||
bell.volume = vol;
|
||||
bell.play().catch(() => {});
|
||||
});
|
||||
|
||||
document.getElementById('clock-settings-save')?.addEventListener('click', () => {
|
||||
const radio = modal.querySelector('input[name="clock-style-radio"]:checked');
|
||||
const style = radio ? radio.value : 'default';
|
||||
safeSet('clock-style', style);
|
||||
safeSet('clock-chimes', String(chimesToggle.checked));
|
||||
safeSet('clock-chime-volume', volumeSlider.value);
|
||||
currentStyle = style;
|
||||
prevFlipDigits = '';
|
||||
tick();
|
||||
modal.classList.remove('show');
|
||||
showNotification('Clock settings saved', 'success', 2000);
|
||||
});
|
||||
|
||||
document.getElementById('clock-settings-cancel')?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
});
|
||||
|
||||
wireModal(modal);
|
||||
|
||||
// Live preview when clicking style options
|
||||
modal?.querySelectorAll('input[name="clock-style-radio"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
currentStyle = radio.value;
|
||||
prevFlipDigits = '';
|
||||
tick();
|
||||
});
|
||||
});
|
||||
})();
|
||||
387
status/js/core/credentials.js
Normal file
387
status/js/core/credentials.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// ========== CREDENTIAL MANAGEMENT ==========
|
||||
(function () {
|
||||
|
||||
// Inject the token-management modal HTML
|
||||
injectModal('token-management-modal', `
|
||||
<div id="token-management-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3>🔑 DNS Credentials</h3>
|
||||
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates.
|
||||
</p>
|
||||
|
||||
<!-- DNS1 Credentials -->
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">DNS1 (Windows)</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="dns1-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="dns1-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="dns1-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="dns1-readonly-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-field">
|
||||
<label for="dns1-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="dns1-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="dns1-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="dns1-admin-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-status" id="dns1-token-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- DNS2 Credentials -->
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">DNS2 (Linux)</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="dns2-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="dns2-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="dns2-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="dns2-readonly-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-field">
|
||||
<label for="dns2-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="dns2-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="dns2-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="dns2-admin-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-status" id="dns2-token-status"></div>
|
||||
</div>
|
||||
|
||||
<!-- DNS3 Credentials -->
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">DNS3 (AlmaLinux)</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="dns3-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="dns3-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="dns3-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="dns3-readonly-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-field">
|
||||
<label for="dns3-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="dns3-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="dns3-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="dns3-admin-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-status" id="dns3-token-status"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
|
||||
<button id="token-cancel">Cancel</button>
|
||||
<button id="token-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Simple encryption for storing credentials - key is generated per installation
|
||||
function getEncryptionKey() {
|
||||
let key = safeGet('dashcaddy-encryption-key');
|
||||
if (!key) {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
key = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
|
||||
safeSet('dashcaddy-encryption-key', key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
const ENCRYPTION_KEY = getEncryptionKey();
|
||||
|
||||
function simpleEncrypt(text, key) {
|
||||
if (!text) return '';
|
||||
let result = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
return btoa(result);
|
||||
}
|
||||
|
||||
function simpleDecrypt(encryptedText, key) {
|
||||
if (!encryptedText) return '';
|
||||
try {
|
||||
const decoded = atob(encryptedText);
|
||||
let result = '';
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length);
|
||||
result += String.fromCharCode(charCode);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Credential storage functions
|
||||
function getCredential(dnsId, tokenType, credType) {
|
||||
const encrypted = safeGet(`${dnsId}-${tokenType}-${credType}-enc`);
|
||||
return simpleDecrypt(encrypted, ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
function setCredential(dnsId, tokenType, credType, value) {
|
||||
const key = `${dnsId}-${tokenType}-${credType}-enc`;
|
||||
if (value) {
|
||||
safeSet(key, simpleEncrypt(value, ENCRYPTION_KEY));
|
||||
} else {
|
||||
safeRemove(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(dnsId, tokenType) {
|
||||
return getCredential(dnsId, tokenType, 'token');
|
||||
}
|
||||
|
||||
function getUsername(dnsId, tokenType) {
|
||||
return getCredential(dnsId, tokenType, 'username');
|
||||
}
|
||||
|
||||
function setToken(dnsId, tokenType, token) {
|
||||
setCredential(dnsId, tokenType, 'token', token);
|
||||
}
|
||||
|
||||
function setUsername(dnsId, tokenType, username) {
|
||||
setCredential(dnsId, tokenType, 'username', username);
|
||||
}
|
||||
|
||||
function getAllCredentials() {
|
||||
return {
|
||||
dns1: {
|
||||
readonly: { username: getUsername('dns1', 'readonly'), token: getToken('dns1', 'readonly') },
|
||||
admin: { username: getUsername('dns1', 'admin'), token: getToken('dns1', 'admin') }
|
||||
},
|
||||
dns2: {
|
||||
readonly: { username: getUsername('dns2', 'readonly'), token: getToken('dns2', 'readonly') },
|
||||
admin: { username: getUsername('dns2', 'admin'), token: getToken('dns2', 'admin') }
|
||||
},
|
||||
dns3: {
|
||||
readonly: { username: getUsername('dns3', 'readonly'), token: getToken('dns3', 'readonly') },
|
||||
admin: { username: getUsername('dns3', 'admin'), token: getToken('dns3', 'admin') }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function clearAllCredentials() {
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
['readonly', 'admin'].forEach(tokenType => {
|
||||
['token', 'username'].forEach(credType => {
|
||||
safeRemove(`${dnsId}-${tokenType}-${credType}-enc`);
|
||||
});
|
||||
});
|
||||
safeRemove(`${dnsId}-token-enc`);
|
||||
safeRemove(`${dnsId}-username-enc`);
|
||||
});
|
||||
}
|
||||
|
||||
function getStoredCredentials(dnsId) {
|
||||
const readonlyToken = getToken(dnsId, 'readonly');
|
||||
const readonlyUsername = getUsername(dnsId, 'readonly');
|
||||
const adminToken = getToken(dnsId, 'admin');
|
||||
const adminUsername = getUsername(dnsId, 'admin');
|
||||
const oldToken = simpleDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY);
|
||||
const oldUsername = simpleDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
username: adminUsername || readonlyUsername || oldUsername,
|
||||
token: adminToken || readonlyToken || oldToken,
|
||||
readonlyToken: readonlyToken || oldToken,
|
||||
readonlyUsername: readonlyUsername || oldUsername,
|
||||
adminToken: adminToken || oldToken,
|
||||
adminUsername: adminUsername || oldUsername
|
||||
};
|
||||
}
|
||||
|
||||
// Token Management Modal handlers
|
||||
document.getElementById('manage-tokens')?.addEventListener('click', () => {
|
||||
const modal = document.getElementById('token-management-modal');
|
||||
const creds = getAllCredentials();
|
||||
|
||||
// Populate fields with existing credentials
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-readonly-username`).value = creds[dnsId].readonly.username;
|
||||
document.getElementById(`${dnsId}-readonly-token`).value = creds[dnsId].readonly.token;
|
||||
document.getElementById(`${dnsId}-admin-username`).value = creds[dnsId].admin.username;
|
||||
document.getElementById(`${dnsId}-admin-token`).value = creds[dnsId].admin.token;
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '';
|
||||
});
|
||||
|
||||
modal.classList.add('show');
|
||||
});
|
||||
|
||||
// Toggle password visibility
|
||||
document.querySelectorAll('.token-toggle').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const targetId = btn.dataset.target;
|
||||
const input = document.getElementById(targetId);
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
btn.textContent = '\u{1F648}';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
btn.textContent = '\u{1F441}';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('token-save')?.addEventListener('click', async () => {
|
||||
// Save all credentials to localStorage
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
|
||||
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
|
||||
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
|
||||
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
|
||||
});
|
||||
|
||||
// Build per-server credentials payload for backend sync
|
||||
const servers = {};
|
||||
let hasAnyCreds = false;
|
||||
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
const entry = {};
|
||||
const roUser = document.getElementById(`${dnsId}-readonly-username`).value.trim();
|
||||
const roPass = document.getElementById(`${dnsId}-readonly-token`).value.trim();
|
||||
const adminUser = document.getElementById(`${dnsId}-admin-username`).value.trim();
|
||||
const adminPass = document.getElementById(`${dnsId}-admin-token`).value.trim();
|
||||
|
||||
if (roUser && roPass) {
|
||||
entry.readonly = { username: roUser, password: roPass };
|
||||
hasAnyCreds = true;
|
||||
}
|
||||
if (adminUser && adminPass) {
|
||||
entry.admin = { username: adminUser, password: adminPass };
|
||||
hasAnyCreds = true;
|
||||
}
|
||||
if (Object.keys(entry).length > 0) {
|
||||
servers[dnsId] = entry;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyCreds) {
|
||||
// Show syncing status
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = 'Verifying...';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/dns/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ servers })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.results) {
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
const statusEl = document.getElementById(`${dnsId}-token-status`);
|
||||
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
|
||||
const result = data.results[dnsId];
|
||||
if (result?.success) {
|
||||
statusEl.textContent = '\u2713 Verified & saved';
|
||||
statusEl.className = 'token-status success';
|
||||
} else if (result?.partial) {
|
||||
statusEl.textContent = '\u2713 ' + result.partial;
|
||||
statusEl.className = 'token-status success';
|
||||
} else {
|
||||
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
|
||||
statusEl.className = 'token-status error';
|
||||
}
|
||||
});
|
||||
} else if (data.success) {
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to sync DNS credentials to backend:', e);
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
if (servers[dnsId]) {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-close after delay if all succeeded
|
||||
setTimeout(() => {
|
||||
const allGood = ['dns1', 'dns2', 'dns3'].every(dnsId => {
|
||||
const status = document.getElementById(`${dnsId}-token-status`).textContent;
|
||||
return !status || status.includes('\u2713');
|
||||
});
|
||||
if (allGood) closeModal('token-management-modal');
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
document.getElementById('token-cancel')?.addEventListener('click', () => {
|
||||
closeModal('token-management-modal');
|
||||
});
|
||||
|
||||
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
|
||||
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
|
||||
clearAllCredentials();
|
||||
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
|
||||
document.getElementById(`${dnsId}-readonly-username`).value = '';
|
||||
document.getElementById(`${dnsId}-readonly-token`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-username`).value = '';
|
||||
document.getElementById(`${dnsId}-admin-token`).value = '';
|
||||
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
|
||||
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
|
||||
});
|
||||
try {
|
||||
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.getElementById('token-management-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'token-management-modal') {
|
||||
e.target.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Window exports
|
||||
window.getToken = getToken;
|
||||
window.getUsername = getUsername;
|
||||
window.setToken = setToken;
|
||||
window.setUsername = setUsername;
|
||||
window.getAllCredentials = getAllCredentials;
|
||||
window.getCredential = getCredential;
|
||||
window.setCredential = setCredential;
|
||||
window.getEncryptionKey = getEncryptionKey;
|
||||
|
||||
})();
|
||||
273
status/js/core/dns.js
Normal file
273
status/js/core/dns.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// ========== DNS MANAGEMENT ==========
|
||||
(function () {
|
||||
// Restart DNS service via backend proxy (backend handles auth automatically)
|
||||
async function restartDnsService(dnsId) {
|
||||
const response = await secureFetch(`/api/v1/dns/restart/${dnsId}`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Restart failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Event delegation for DNS restart buttons (dynamic cards)
|
||||
document.querySelector('.top')?.addEventListener('click', async (e) => {
|
||||
const restartBtn = e.target.closest('[id$="-restart"]');
|
||||
if (!restartBtn) return;
|
||||
const dnsId = restartBtn.id.replace('-restart', '');
|
||||
if (!SITE.dnsServers[dnsId]) return;
|
||||
if (!confirm(`Restart ${dnsId.toUpperCase()} service?`)) return;
|
||||
try {
|
||||
await withButton(restartBtn, '...', () => restartDnsService(dnsId));
|
||||
setTimeout(window.refreshAll, DC.DELAYS.RELOAD);
|
||||
} catch (e) {
|
||||
showNotification('Restart failed: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// DNS Update buttons
|
||||
async function updateDnsServer(dnsId, serverIp) {
|
||||
const btn = document.getElementById(`${dnsId}-update`);
|
||||
const originalText = btn?.textContent || '⬆️';
|
||||
|
||||
try {
|
||||
// First check for updates
|
||||
btn.textContent = '🔍';
|
||||
btn.disabled = true;
|
||||
btn.title = 'Checking for updates...';
|
||||
|
||||
const checkResponse = await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(serverIp)}`);
|
||||
const checkResult = await checkResponse.json();
|
||||
|
||||
if (!checkResult.success) {
|
||||
throw new Error(checkResult.error || 'Failed to check for updates');
|
||||
}
|
||||
|
||||
if (!checkResult.updateAvailable) {
|
||||
btn.textContent = '✅';
|
||||
btn.title = `Already on latest version (${checkResult.currentVersion})`;
|
||||
showNotification(`${dnsId.toUpperCase()} is already up to date! Current version: ${checkResult.currentVersion}`, 'info');
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
btn.title = 'Update DNS server';
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update available - confirm with user
|
||||
const confirmUpdate = confirm(
|
||||
`Update available for ${dnsId.toUpperCase()}!\n\n` +
|
||||
`Current: ${checkResult.currentVersion}\n` +
|
||||
`New: ${checkResult.updateVersion}\n\n` +
|
||||
(checkResult.updateTitle ? `${checkResult.updateTitle}\n\n` : '') +
|
||||
`The DNS server will restart during the update.\nProceed?`
|
||||
);
|
||||
|
||||
if (!confirmUpdate) {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
btn.title = 'Update DNS server';
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
btn.textContent = '🔄';
|
||||
btn.title = 'Updating...';
|
||||
|
||||
const updateResponse = await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(serverIp)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const updateResult = await updateResponse.json();
|
||||
|
||||
if (!updateResult.success) {
|
||||
throw new Error(updateResult.error || 'Update failed');
|
||||
}
|
||||
|
||||
if (updateResult.manualUpdateRequired) {
|
||||
// Technitium v14+ doesn't support auto-install via API
|
||||
btn.textContent = '⬆️';
|
||||
btn.title = `Update available: ${updateResult.newVersion}`;
|
||||
const downloadInfo = updateResult.downloadLink
|
||||
? `\nDownload: ${updateResult.downloadLink}`
|
||||
: '';
|
||||
const instructionsInfo = updateResult.instructionsLink
|
||||
? `\nInstructions: ${updateResult.instructionsLink}`
|
||||
: '';
|
||||
showNotification(`${dnsId.toUpperCase()} update requires manual installation. Current: ${updateResult.previousVersion} → ${updateResult.newVersion}. Please update manually on the host machine.`, 'warning', 8000);
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
btn.textContent = '✅';
|
||||
btn.title = 'Updated successfully!';
|
||||
showNotification(`${dnsId.toUpperCase()} updated successfully! ${updateResult.previousVersion} → ${updateResult.newVersion}. Server is restarting...`, 'success');
|
||||
|
||||
// Refresh after delay for server restart
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
btn.title = 'Update DNS server';
|
||||
window.refreshAll();
|
||||
}, 10000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('DNS update error:', error);
|
||||
btn.textContent = '❌';
|
||||
btn.title = 'Update failed';
|
||||
showNotification(`Failed to update ${dnsId.toUpperCase()}: ${error.message}`, 'error');
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
btn.title = 'Update DNS server';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for DNS update buttons (dynamic cards)
|
||||
document.querySelector('.top')?.addEventListener('click', (e) => {
|
||||
const updateBtn = e.target.closest('[id$="-update"]');
|
||||
if (!updateBtn) return;
|
||||
const dnsId = updateBtn.id.replace('-update', '');
|
||||
if (!SITE.dnsServers[dnsId]) return;
|
||||
updateDnsServer(dnsId, SITE.dnsServers[dnsId]?.ip);
|
||||
});
|
||||
|
||||
// ===== DNS SETTINGS MODAL =====
|
||||
|
||||
// Inject modal HTML
|
||||
injectModal('dns-settings-modal', `
|
||||
<div id="dns-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 id="dns-settings-title">DNS Settings</h3>
|
||||
|
||||
<div style="display: grid; gap: 16px; margin-top: 16px;">
|
||||
<div>
|
||||
<label for="dns-edit-ip" class="form-label-accent-sm">Server IP</label>
|
||||
<input type="text" id="dns-edit-ip" class="form-input-md" placeholder="192.168.1.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="dns-edit-port" class="form-label-accent-sm">Port</label>
|
||||
<input type="number" id="dns-edit-port" class="form-input-md" placeholder="5380" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="dns-edit-name" class="form-label-accent-sm">Display Name (optional)</label>
|
||||
<input type="text" id="dns-edit-name" class="form-input-md" placeholder="e.g. Primary DNS" />
|
||||
</div>
|
||||
<div class="form-hint-sm">Manage credentials via Tokens in the toolbar</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||
<button id="dns-settings-cancel">Cancel</button>
|
||||
<button id="dns-settings-delete" style="background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">Remove</button>
|
||||
<button id="dns-settings-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
let currentDnsId = null;
|
||||
|
||||
function openDnsSettings(dnsId) {
|
||||
currentDnsId = dnsId;
|
||||
const server = SITE.dnsServers[dnsId] || {};
|
||||
const modal = document.getElementById('dns-settings-modal');
|
||||
|
||||
document.getElementById('dns-settings-title').textContent = `${(server.name || dnsId).toUpperCase()} Settings`;
|
||||
document.getElementById('dns-edit-ip').value = server.ip || '';
|
||||
document.getElementById('dns-edit-port').value = server.port || DC.DEFAULTS.DNS_PORT;
|
||||
document.getElementById('dns-edit-name').value = server.name || '';
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
async function saveDnsSettings() {
|
||||
if (!currentDnsId) return;
|
||||
const ip = document.getElementById('dns-edit-ip').value.trim();
|
||||
const port = document.getElementById('dns-edit-port').value.trim() || DC.DEFAULTS.DNS_PORT;
|
||||
const name = document.getElementById('dns-edit-name').value.trim();
|
||||
|
||||
if (!ip) {
|
||||
showNotification('Server IP is required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const update = { dnsServers: {} };
|
||||
update.dnsServers[currentDnsId] = { ip, port: String(port) };
|
||||
if (name) update.dnsServers[currentDnsId].name = name;
|
||||
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(update)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
SITE.dnsServers[currentDnsId] = update.dnsServers[currentDnsId];
|
||||
showNotification(`${currentDnsId.toUpperCase()} settings saved`, 'success');
|
||||
closeDnsSettings();
|
||||
window.refreshAll();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to save settings', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('Failed to save: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDnsServer() {
|
||||
if (!currentDnsId) return;
|
||||
if (!confirm(`Remove ${currentDnsId.toUpperCase()} from dashboard? This won't affect the actual DNS server.`)) return;
|
||||
|
||||
// Remove by setting to null in dnsServers
|
||||
try {
|
||||
const resp = await secureFetch('/api/v1/config');
|
||||
const config = await resp.json();
|
||||
if (config.dnsServers) {
|
||||
delete config.dnsServers[currentDnsId];
|
||||
}
|
||||
const saveResp = await secureFetch('/api/v1/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dnsServers: config.dnsServers || {} })
|
||||
});
|
||||
const result = await saveResp.json();
|
||||
if (result.success) {
|
||||
delete SITE.dnsServers[currentDnsId];
|
||||
// Remove card from DOM
|
||||
const card = document.querySelector(`.top [data-app="${currentDnsId}"]`);
|
||||
if (card) card.remove();
|
||||
showNotification(`${currentDnsId.toUpperCase()} removed from dashboard`, 'success');
|
||||
closeDnsSettings();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to remove', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification('Failed to remove: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeDnsSettings() {
|
||||
closeModal('dns-settings-modal');
|
||||
currentDnsId = null;
|
||||
}
|
||||
|
||||
// Event listeners for modal buttons
|
||||
document.getElementById('dns-settings-cancel')?.addEventListener('click', closeDnsSettings);
|
||||
document.getElementById('dns-settings-save')?.addEventListener('click', saveDnsSettings);
|
||||
document.getElementById('dns-settings-delete')?.addEventListener('click', removeDnsServer);
|
||||
document.getElementById('dns-settings-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'dns-settings-modal') closeDnsSettings();
|
||||
});
|
||||
// Event delegation for DNS settings buttons (dynamic cards)
|
||||
document.querySelector('.top')?.addEventListener('click', (e) => {
|
||||
const settingsBtn = e.target.closest('[id$="-settings"]');
|
||||
if (!settingsBtn) return;
|
||||
const dnsId = settingsBtn.id.replace('-settings', '');
|
||||
if (!SITE.dnsServers[dnsId]) return;
|
||||
e.stopPropagation();
|
||||
openDnsSettings(dnsId);
|
||||
});
|
||||
|
||||
document.getElementById('refresh')?.addEventListener('click', window.refreshAll);
|
||||
})();
|
||||
384
status/js/core/grid.js
Normal file
384
status/js/core/grid.js
Normal file
@@ -0,0 +1,384 @@
|
||||
// ========== GRID & STATUS HELPERS ==========
|
||||
(function () {
|
||||
|
||||
/* Enhanced status helpers with response time tracking */
|
||||
function setQuick(id, up, responseTime = null) {
|
||||
const dot = document.getElementById(id + '-dot');
|
||||
const pill = document.getElementById(id + '-pill');
|
||||
const timeEl = document.getElementById(id + '-time');
|
||||
const card = document.querySelector(`[data-app="${id}"]`);
|
||||
|
||||
if (dot) {
|
||||
dot.classList.toggle('ok', up);
|
||||
dot.classList.toggle('bad', !up);
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.textContent = up ? 'ON' : 'OFF';
|
||||
pill.classList.toggle('on', up);
|
||||
pill.classList.toggle('off', !up);
|
||||
}
|
||||
|
||||
if (timeEl && responseTime !== null) {
|
||||
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
|
||||
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
|
||||
}
|
||||
|
||||
// Update card status for icon coloring
|
||||
if (card) {
|
||||
card.setAttribute('data-status', up ? 'on' : 'off');
|
||||
}
|
||||
|
||||
// Internet card packet blink effect
|
||||
if (id === 'internet' && dot && up) {
|
||||
blinkInternetPacket(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// Internet card packet activity blink
|
||||
let internetBlinkInterval = null;
|
||||
function blinkInternetPacket(dot) {
|
||||
// Alternate between rx (green) and tx (blue) to simulate bidirectional traffic
|
||||
const isRx = Math.random() > 0.5;
|
||||
dot.classList.add(isRx ? 'packet-rx' : 'packet-tx');
|
||||
setTimeout(() => {
|
||||
dot.classList.remove('packet-rx', 'packet-tx');
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// Continuous packet simulation for Internet card when online
|
||||
function startInternetPacketSimulation() {
|
||||
if (internetBlinkInterval) return;
|
||||
internetBlinkInterval = setInterval(() => {
|
||||
const dot = document.getElementById('internet-dot');
|
||||
const card = document.querySelector('[data-app="internet"]');
|
||||
if (dot && card && card.getAttribute('data-status') === 'on') {
|
||||
blinkInternetPacket(dot);
|
||||
}
|
||||
}, 300 + Math.random() * 400); // Random interval 300-700ms
|
||||
}
|
||||
|
||||
function stopInternetPacketSimulation() {
|
||||
if (internetBlinkInterval) {
|
||||
clearInterval(internetBlinkInterval);
|
||||
internetBlinkInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Start simulation on page load
|
||||
startInternetPacketSimulation();
|
||||
|
||||
function getResponseTimeClass(time, isUp) {
|
||||
if (!isUp) return 'timeout';
|
||||
if (time < 200) return 'excellent';
|
||||
if (time < 500) return 'good';
|
||||
if (time < 1000) return 'fair';
|
||||
return 'slow';
|
||||
}
|
||||
|
||||
async function checkServiceWithTiming(id) {
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
const r = await fetch('/probe/' + id, { cache: 'no-store' });
|
||||
const endTime = performance.now();
|
||||
const responseTime = Math.round(endTime - startTime);
|
||||
const isUp = (r.status >= 200 && r.status < 400) || r.status === 401 || r.status === 403;
|
||||
return { isUp, responseTime };
|
||||
} catch {
|
||||
const endTime = performance.now();
|
||||
const responseTime = Math.round(endTime - startTime);
|
||||
return { isUp: false, responseTime };
|
||||
}
|
||||
}
|
||||
|
||||
/* App grid - loaded from API */
|
||||
window.APPS = []; // Use window.APPS as the main array
|
||||
|
||||
// Load services from API
|
||||
async function loadServices() {
|
||||
try {
|
||||
if (window.SkeletonLoader) window.SkeletonLoader.show(6);
|
||||
const response = await fetch('/api/v1/services', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
window.APPS = await response.json();
|
||||
if (window.SkeletonLoader) window.SkeletonLoader.hide();
|
||||
} else {
|
||||
console.error('Failed to load services:', response.status);
|
||||
if (window.SkeletonLoader) window.SkeletonLoader.hide();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load services:', error);
|
||||
if (window.SkeletonLoader) window.SkeletonLoader.hide();
|
||||
}
|
||||
}
|
||||
|
||||
function serviceUrl(id) { return `https://${buildDomain(id)}`; }
|
||||
function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; }
|
||||
|
||||
function buildGrid() {
|
||||
const root = document.getElementById('cards'); root.innerHTML = '';
|
||||
for (let i = 0; i < window.APPS.length; i++) {
|
||||
const s = window.APPS[i];
|
||||
if (s.id === 'ca') continue; // DashCA lives in top anchor row
|
||||
const card = el('div', 'card');
|
||||
card.setAttribute('data-app', s.id);
|
||||
card.setAttribute('data-status', 'off'); // Initial status
|
||||
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
||||
|
||||
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
||||
|
||||
const row = el('div', 'row');
|
||||
const wrap = el('div', 'logo-wrap');
|
||||
|
||||
// Use reliable PNG images with automatic CDN fallback
|
||||
const img = document.createElement('img');
|
||||
img.src = s.logo;
|
||||
img.alt = s.name;
|
||||
img.className = 'logo-img';
|
||||
img.onerror = function() {
|
||||
// Try CDN fallback with multiple naming strategies
|
||||
// Use id, appTemplate, or derive from name
|
||||
let appId = s.id || s.appTemplate;
|
||||
if (!appId && s.name) {
|
||||
// Derive ID from name (lowercase, remove spaces)
|
||||
appId = s.name.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
if (appId) {
|
||||
// Try different CDN URL formats
|
||||
const cdnUrls = [
|
||||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId}.png`,
|
||||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.toLowerCase()}.png`,
|
||||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.replace(/-/g, '')}.png`,
|
||||
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${s.name.toLowerCase().replace(/\s+/g, '-')}.png`
|
||||
];
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueUrls = [...new Set(cdnUrls)];
|
||||
|
||||
// Find the next URL to try
|
||||
const currentIndex = uniqueUrls.indexOf(this.src);
|
||||
const nextIndex = currentIndex + 1;
|
||||
|
||||
if (nextIndex < uniqueUrls.length) {
|
||||
this.src = uniqueUrls[nextIndex];
|
||||
} else {
|
||||
this.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
this.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
wrap.appendChild(img);
|
||||
row.appendChild(wrap);
|
||||
|
||||
const nameSpan = el('span', 'name', s.name);
|
||||
row.appendChild(nameSpan);
|
||||
|
||||
// Add Tailscale badge if service is protected
|
||||
if (s.tailscaleOnly) {
|
||||
const tsBadge = el('span', 'ts-badge', '🔐');
|
||||
tsBadge.title = 'Tailscale-only access';
|
||||
tsBadge.style.cssText = 'margin-left: 6px; font-size: 0.75rem; opacity: 0.8;';
|
||||
nameSpan.appendChild(tsBadge);
|
||||
}
|
||||
|
||||
row.appendChild(el('span', 'spacer'));
|
||||
|
||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||
|
||||
// Update available badge (hidden by default, shown when update detected)
|
||||
const updateBadge = el('span', 'update-available-badge', 'UPDATE');
|
||||
updateBadge.id = 'update-badge-' + s.id;
|
||||
updateBadge.title = 'Update available';
|
||||
row.appendChild(updateBadge);
|
||||
|
||||
card.appendChild(row);
|
||||
|
||||
// Add response time row
|
||||
const responseRow = el('div', 'response-row');
|
||||
const timeSpan = el('span', 'response-time', '--'); timeSpan.id = 'time-' + s.id;
|
||||
responseRow.appendChild(timeSpan);
|
||||
card.appendChild(responseRow);
|
||||
|
||||
// Add health/uptime row
|
||||
const healthRow = el('div', 'health-row');
|
||||
healthRow.id = 'health-' + s.id;
|
||||
const uptimeChip = el('span', 'uptime-chip', '--');
|
||||
uptimeChip.id = 'uptime-' + s.id;
|
||||
healthRow.appendChild(uptimeChip);
|
||||
const uptimeMiniBar = document.createElement('div');
|
||||
uptimeMiniBar.className = 'uptime-mini-bar';
|
||||
const uptimeFill = document.createElement('div');
|
||||
uptimeFill.className = 'fill';
|
||||
uptimeFill.id = 'uptime-bar-' + s.id;
|
||||
uptimeFill.style.width = '0%';
|
||||
uptimeMiniBar.appendChild(uptimeFill);
|
||||
healthRow.appendChild(uptimeMiniBar);
|
||||
card.appendChild(healthRow);
|
||||
|
||||
const btnRow = el('div', 'btn-row');
|
||||
|
||||
// Add logs button for services with containerIds
|
||||
if (s.containerId) {
|
||||
const logsBtn = el('button', 'logs-btn', '📋');
|
||||
logsBtn.title = 'View container logs';
|
||||
logsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
window.openContainerLogsModal(s.containerId, s.name);
|
||||
};
|
||||
btnRow.appendChild(logsBtn);
|
||||
|
||||
// Add update button for Docker containers
|
||||
const updateBtn = el('button', 'update-btn', '⬆️');
|
||||
updateBtn.title = 'Update container to latest version';
|
||||
updateBtn.id = `update-btn-${s.id}`;
|
||||
updateBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
window.updateContainer(s.containerId, s.name, s.id);
|
||||
};
|
||||
btnRow.appendChild(updateBtn);
|
||||
}
|
||||
|
||||
// Add logs button for services with logPath (native apps)
|
||||
if (s.logPath && !s.containerId) {
|
||||
const logsBtn = el('button', 'logs-btn', '📋');
|
||||
logsBtn.title = 'View application logs';
|
||||
logsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
window.openFileLogsModal(s.logPath, s.name);
|
||||
};
|
||||
btnRow.appendChild(logsBtn);
|
||||
}
|
||||
|
||||
// Add credentials button for services that support auto-login
|
||||
if (s.isExternal || s.appTemplate || s.url) {
|
||||
const credsBtn = el('button', 'creds-btn', '🔑');
|
||||
credsBtn.title = 'Auto-login credentials';
|
||||
credsBtn.id = `creds-btn-${s.id}`;
|
||||
credsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
window.openServiceCredsModal(s);
|
||||
};
|
||||
btnRow.appendChild(credsBtn);
|
||||
}
|
||||
|
||||
// Add options button for all services except 'internet'
|
||||
if (s.id !== 'internet') {
|
||||
const optBtn = el('button', 'options-btn', '⚙️');
|
||||
optBtn.title = 'Edit service settings';
|
||||
optBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
window.openServiceEditModal(s);
|
||||
};
|
||||
btnRow.appendChild(optBtn);
|
||||
}
|
||||
|
||||
// Add delete button for all services except Internet
|
||||
if (s.id !== 'internet') {
|
||||
const delBtn = el('button', 'delete-btn', '🗑️');
|
||||
delBtn.title = 'Delete this service';
|
||||
delBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
window.deleteService(s.id, s.name);
|
||||
};
|
||||
btnRow.appendChild(delBtn);
|
||||
}
|
||||
|
||||
const btn = el('button', null, 'Open');
|
||||
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
|
||||
btnRow.appendChild(btn);
|
||||
card.appendChild(btnRow);
|
||||
|
||||
root.appendChild(card);
|
||||
|
||||
// Staggered loading animation
|
||||
setTimeout(() => {
|
||||
card.classList.add('loaded');
|
||||
}, i * 100); // 100ms delay between each card
|
||||
}
|
||||
|
||||
// Group recipe cards visually after grid is built
|
||||
if (window.groupRecipeCards) setTimeout(window.groupRecipeCards, 50);
|
||||
}
|
||||
|
||||
function setBadge(id, up, responseTime = null) {
|
||||
const dot = document.getElementById('dot-' + id + '-grid');
|
||||
const pill = document.getElementById('badge-' + id);
|
||||
const timeEl = document.getElementById('time-' + id);
|
||||
const card = document.querySelector(`[data-app="${id}"]`);
|
||||
|
||||
if (dot) {
|
||||
dot.classList.toggle('ok', up);
|
||||
dot.classList.toggle('bad', !up);
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.textContent = up ? 'ON' : 'OFF';
|
||||
pill.classList.toggle('on', up);
|
||||
pill.classList.toggle('off', !up);
|
||||
}
|
||||
|
||||
if (timeEl && responseTime !== null) {
|
||||
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
|
||||
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
|
||||
}
|
||||
|
||||
// Update card status for icon coloring
|
||||
if (card) {
|
||||
card.setAttribute('data-status', up ? 'on' : 'off');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
// Check DNS servers dynamically (only those configured in SITE.dnsServers)
|
||||
const dnsIds = Object.keys(SITE.dnsServers);
|
||||
const topChecks = dnsIds.map(id => checkServiceWithTiming(id));
|
||||
topChecks.push(checkServiceWithTiming('internet'));
|
||||
const topResults = await Promise.all(topChecks);
|
||||
|
||||
dnsIds.forEach((id, i) => setQuick(id, topResults[i].isUp, topResults[i].responseTime));
|
||||
const internetResult = topResults[topResults.length - 1];
|
||||
setQuick('internet', internetResult.isUp, internetResult.responseTime);
|
||||
|
||||
// Check app services with timing
|
||||
const appResults = await Promise.all(
|
||||
window.APPS.map(async s => {
|
||||
const result = await checkServiceWithTiming(s.id);
|
||||
return { id: s.id, ...result };
|
||||
})
|
||||
);
|
||||
|
||||
appResults.forEach(result => {
|
||||
setBadge(result.id, result.isUp, result.responseTime);
|
||||
});
|
||||
|
||||
const stamp = document.getElementById('stamp');
|
||||
if (stamp) stamp.textContent = 'last check: ' + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
// DNS open buttons — use event delegation on .top container
|
||||
document.querySelector('.top')?.addEventListener('click', (e) => {
|
||||
const openBtn = e.target.closest('[id$="-open"]');
|
||||
if (!openBtn) return;
|
||||
const id = openBtn.id.replace('-open', '');
|
||||
if (SITE.dnsServers[id]) window.open(serviceUrl(id), '_blank', 'noopener');
|
||||
});
|
||||
document.getElementById('ca-open')?.addEventListener('click', () => window.open(serviceUrl('ca'), '_blank', 'noopener'));
|
||||
document.getElementById('creds-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceCredsModal) window.openServiceCredsModal(s); });
|
||||
document.getElementById('options-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceEditModal) window.openServiceEditModal(s); });
|
||||
document.getElementById('delete-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); if (window.deleteService) window.deleteService('ca', 'DashCA'); });
|
||||
|
||||
// Window exports
|
||||
window.loadServices = loadServices;
|
||||
window.buildGrid = buildGrid;
|
||||
window.refreshAll = refreshAll;
|
||||
window.setQuick = setQuick;
|
||||
window.setBadge = setBadge;
|
||||
window.getResponseTimeClass = getResponseTimeClass;
|
||||
window.checkServiceWithTiming = checkServiceWithTiming;
|
||||
window.serviceUrl = serviceUrl;
|
||||
window.el = el;
|
||||
|
||||
})();
|
||||
204
status/js/core/init.js
Normal file
204
status/js/core/init.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// ========== DASHBOARD INITIALIZATION ==========
|
||||
(function () {
|
||||
|
||||
function loadCustomServices() {
|
||||
const customServices = safeGet('custom-services');
|
||||
if (customServices) {
|
||||
try {
|
||||
const services = JSON.parse(customServices);
|
||||
// Merge with default APPS, avoiding duplicates
|
||||
services.forEach(service => {
|
||||
if (!window.APPS.find(app => app.id === service.id)) {
|
||||
window.APPS.push(service);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to load custom services:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize custom services immediately so window.APPS is populated before buildGrid runs
|
||||
loadCustomServices();
|
||||
|
||||
// Staggered animation for top cards too
|
||||
function animateTopCards() {
|
||||
const topCards = document.querySelectorAll('.top .card');
|
||||
topCards.forEach((card, index) => {
|
||||
card.style.opacity = '0';
|
||||
card.style.transform = 'translateY(20px)';
|
||||
setTimeout(() => {
|
||||
card.style.transition = 'opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
card.style.opacity = '1';
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, index * 150); // 150ms delay between top cards
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dashboard (called after TOTP gate check or directly if TOTP disabled)
|
||||
// NOTE: loadServices comes from window.loadServices (exported by grid.js)
|
||||
let _dashboardInitialized = false;
|
||||
async function initializeDashboard() {
|
||||
if (_dashboardInitialized) {
|
||||
console.warn('[init] initializeDashboard called again, skipping duplicate');
|
||||
return;
|
||||
}
|
||||
_dashboardInitialized = true;
|
||||
await window.loadServices();
|
||||
window.buildGrid();
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
||||
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
||||
if (typeof window._updateAuthCard === 'function') {
|
||||
try {
|
||||
const r = await fetch('/api/v1/totp/config', { cache: 'no-store' });
|
||||
const d = await r.json();
|
||||
if (d.success) window._updateAuthCard(d.config.enabled && d.config.isSetUp, d.config.sessionDuration);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
// Lazy-load onboarding for first-time users, otherwise just add the tour button
|
||||
addTourButton();
|
||||
if (shouldLoadOnboarding()) {
|
||||
loadOnboarding();
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load onboarding bundle (52 KB) — only loaded when needed
|
||||
function loadOnboarding() {
|
||||
if (document.querySelector('script[src="/dist/onboarding.js"]')) return; // already loading/loaded
|
||||
const s = document.createElement('script');
|
||||
s.src = '/dist/onboarding.js';
|
||||
s.defer = true;
|
||||
document.head.appendChild(s);
|
||||
// Also load onboarding CSS if not already present
|
||||
if (!document.querySelector('link[href="/css/driver.min.css"]')) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/css/driver.min.css';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (!document.querySelector('link[href="/css/onboarding.css"]')) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/css/onboarding.css';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if onboarding should auto-start (first-time user)
|
||||
function shouldLoadOnboarding() {
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding'));
|
||||
return !data || (!data.tourCompleted && data.currentStep === 0);
|
||||
} catch (_) {
|
||||
return true; // No data means first-time user
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Collapsible toolbar sections =====
|
||||
function initToolbarSections() {
|
||||
const sections = document.querySelectorAll('.tools-section');
|
||||
if (!sections.length) return;
|
||||
|
||||
// Restore saved state from localStorage
|
||||
let saved = {};
|
||||
try { saved = JSON.parse(localStorage.getItem('toolbar-sections') || '{}'); } catch (_) {}
|
||||
|
||||
sections.forEach(section => {
|
||||
const key = section.dataset.section;
|
||||
const header = section.querySelector('.tools-section-header');
|
||||
if (!header) return;
|
||||
|
||||
// Restore state (default: collapsed)
|
||||
if (saved[key]) {
|
||||
section.classList.add('open');
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
header.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const isOpen = section.classList.toggle('open');
|
||||
header.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
|
||||
// Save state
|
||||
const state = {};
|
||||
document.querySelectorAll('.tools-section').forEach(s => {
|
||||
state[s.dataset.section] = s.classList.contains('open');
|
||||
});
|
||||
localStorage.setItem('toolbar-sections', JSON.stringify(state));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize toolbar sections on DOM ready
|
||||
initToolbarSections();
|
||||
|
||||
// Add restart tour button (loads bundle on click if not loaded)
|
||||
// Visible in primary toolbar until tour completed once, then moves to Admin section
|
||||
function addTourButton() {
|
||||
if (document.getElementById('restart-tour-btn')) return;
|
||||
|
||||
// Check if tour has been completed before
|
||||
let tourDone = false;
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding'));
|
||||
tourDone = data && data.tourCompleted;
|
||||
} catch (_) {}
|
||||
|
||||
// Before first completion: show in primary toolbar. After: tuck into Admin section.
|
||||
const target = tourDone
|
||||
? document.querySelector('.tools-section[data-section="admin"] .tools-section-items')
|
||||
: document.querySelector('.tools-primary');
|
||||
if (!target) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.id = 'restart-tour-btn';
|
||||
button.textContent = tourDone ? 'Help Tour' : '🎓 Help Tour';
|
||||
button.title = 'Restart the onboarding tour';
|
||||
button.onclick = () => {
|
||||
if (window.DashCaddyOnboarding) {
|
||||
window.DashCaddyOnboarding.restartTour();
|
||||
} else {
|
||||
loadOnboarding();
|
||||
// Wait for bundle to load, then start
|
||||
const check = setInterval(() => {
|
||||
if (window.DashCaddyOnboarding) {
|
||||
clearInterval(check);
|
||||
window.DashCaddyOnboarding.restartTour();
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(() => clearInterval(check), 5000); // give up after 5s
|
||||
}
|
||||
};
|
||||
target.appendChild(button);
|
||||
}
|
||||
|
||||
window.initializeDashboard = initializeDashboard;
|
||||
window.loadCustomServices = loadCustomServices;
|
||||
|
||||
// TOTP-gated initialization
|
||||
(async () => {
|
||||
try {
|
||||
const totpRes = await fetch('/api/v1/totp/config', { cache: 'no-store' });
|
||||
const totpData = await totpRes.json();
|
||||
|
||||
if (totpData.success && totpData.config.enabled) {
|
||||
// TOTP is enabled - check if we have a valid session
|
||||
const testRes = await fetch('/api/v1/totp/check-session', { cache: 'no-store' });
|
||||
if (testRes.status === 401) {
|
||||
// Need TOTP verification - show overlay
|
||||
window._showTotpOverlay();
|
||||
return; // initializeDashboard() will be called after successful verification
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('TOTP check failed, proceeding normally:', e);
|
||||
}
|
||||
|
||||
// TOTP disabled or session valid - initialize immediately
|
||||
initializeDashboard();
|
||||
})();
|
||||
|
||||
})();
|
||||
672
status/js/core/logs.js
Normal file
672
status/js/core/logs.js
Normal file
@@ -0,0 +1,672 @@
|
||||
// ========== LOG VIEWERS ==========
|
||||
(function () {
|
||||
|
||||
// Inject logs-modal HTML
|
||||
injectModal('logs-modal', `
|
||||
<div id="logs-modal" class="logs-modal">
|
||||
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<div class="logs-header">
|
||||
<h3 id="logs-title">DNS Logs</h3>
|
||||
<div class="logs-controls">
|
||||
<label for="log-lines">Show:</label>
|
||||
<select id="log-lines">
|
||||
<option value="25" selected>25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
</select>
|
||||
<button id="logs-stream" class="stream-btn" title="Enable real-time streaming">📡 Live</button>
|
||||
<button id="logs-pause" class="pause-btn">⏸️ Pause</button>
|
||||
<button id="logs-close" class="close-btn">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logs-container scroll-container">
|
||||
<div id="logs-content" class="logs-content">
|
||||
<div class="logs-loading">Loading logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
// ===== State =====
|
||||
let currentDnsService = null;
|
||||
let logsInterval = null;
|
||||
let logsPaused = false;
|
||||
|
||||
let currentContainerId = null;
|
||||
let currentContainerName = null;
|
||||
let containerLogsMode = false;
|
||||
|
||||
let currentLogPath = null;
|
||||
let currentLogServiceName = null;
|
||||
let fileLogsMode = false;
|
||||
|
||||
let logEventSource = null;
|
||||
let isStreaming = false;
|
||||
|
||||
// ===== DNS LOGS =====
|
||||
|
||||
async function fetchDnsLogs(dnsId, lines = 25) {
|
||||
try {
|
||||
const serverIP = getDnsServerAddr(dnsId);
|
||||
const response = await fetch(`/api/v1/dns/logs?server=${serverIP}&limit=${lines}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.logs) {
|
||||
return { logs: result.logs, count: result.count, server: result.server };
|
||||
} else {
|
||||
return { error: result.error || 'Failed to fetch logs' };
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
return { error: 'DNS auto-auth failed - check credentials in settings' };
|
||||
} else {
|
||||
return { error: `HTTP ${response.status}` };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('DNS logs fetch failed:', error);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function getRcodeColor(rcode) {
|
||||
const colors = {
|
||||
'NoError': 'var(--ok-fg)',
|
||||
'NOERROR': 'var(--ok-fg)',
|
||||
'NxDomain': 'var(--muted)',
|
||||
'NXDOMAIN': 'var(--muted)',
|
||||
'Refused': 'var(--bad-fg)',
|
||||
'REFUSED': 'var(--bad-fg)',
|
||||
'ServerFailure': '#f39c12',
|
||||
'SERVFAIL': '#f39c12'
|
||||
};
|
||||
return colors[rcode] || 'var(--fg)';
|
||||
}
|
||||
|
||||
function renderDnsLogEntry(log) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
div.style.cssText = 'display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;';
|
||||
|
||||
// If unparsed raw log
|
||||
if (log.parsed === false) {
|
||||
div.style.gridTemplateColumns = '1fr';
|
||||
div.innerHTML = `<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(log.raw)}</span>`;
|
||||
return div;
|
||||
}
|
||||
|
||||
const rcodeColor = getRcodeColor(log.rcode);
|
||||
const isBlocked = log.rcode === 'Refused' || log.rcode === 'REFUSED';
|
||||
|
||||
div.innerHTML = `
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(log.timestamp)}</span>
|
||||
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(log.client)}">${escapeHtml(log.client)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${isBlocked ? 'text-decoration: line-through; opacity: 0.6;' : ''}" title="${escapeHtml(log.domain)}">${escapeHtml(log.domain)}</span>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(log.type)}</span>
|
||||
<span style="color: ${rcodeColor}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(log.rcode)}</span>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
async function updateLogsDisplay() {
|
||||
// Handle file logs mode
|
||||
if (fileLogsMode) {
|
||||
await updateFileLogsDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle container logs mode
|
||||
if (containerLogsMode) {
|
||||
await updateContainerLogsDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle DNS logs mode
|
||||
if (logsPaused || !currentDnsService) return;
|
||||
|
||||
const lines = parseInt(document.getElementById('log-lines').value);
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
try {
|
||||
const result = await fetchDnsLogs(currentDnsService, lines);
|
||||
|
||||
if (result.error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||||
<div>${result.error}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add header row
|
||||
logsContent.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span>Time</span>
|
||||
<span>Client</span>
|
||||
<span>Domain</span>
|
||||
<span>Type</span>
|
||||
<span>Status</span>
|
||||
</div>`;
|
||||
|
||||
if (result.logs && result.logs.length > 0) {
|
||||
result.logs.forEach(log => {
|
||||
const logElement = renderDnsLogEntry(log);
|
||||
logsContent.appendChild(logElement);
|
||||
});
|
||||
} else {
|
||||
logsContent.innerHTML += `
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No DNS queries logged yet
|
||||
</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${error.message}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openLogsModal(dnsId) {
|
||||
currentDnsService = dnsId;
|
||||
logsPaused = false;
|
||||
containerLogsMode = false;
|
||||
|
||||
const modal = document.getElementById('logs-modal');
|
||||
const title = document.getElementById('logs-title');
|
||||
const pauseBtn = document.getElementById('logs-pause');
|
||||
const streamBtn = document.getElementById('logs-stream');
|
||||
|
||||
title.textContent = `${dnsId.toUpperCase()} DNS Logs`;
|
||||
pauseBtn.textContent = '⏸️ Pause';
|
||||
pauseBtn.classList.remove('paused');
|
||||
|
||||
// Hide stream button for DNS logs (only available for container logs)
|
||||
if (streamBtn) {
|
||||
streamBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
// Initial load
|
||||
updateLogsDisplay();
|
||||
|
||||
// Start auto-refresh every 3 seconds
|
||||
logsInterval = setInterval(updateLogsDisplay, DC.POLL.LOGS);
|
||||
}
|
||||
|
||||
function closeLogsModal() {
|
||||
const modal = document.getElementById('logs-modal');
|
||||
modal.classList.remove('show');
|
||||
|
||||
if (logsInterval) {
|
||||
clearInterval(logsInterval);
|
||||
logsInterval = null;
|
||||
}
|
||||
|
||||
// Stop SSE streaming if active
|
||||
stopLogStreaming();
|
||||
|
||||
// Reset all log modes
|
||||
currentDnsService = null;
|
||||
containerLogsMode = false;
|
||||
currentContainerId = null;
|
||||
currentContainerName = null;
|
||||
fileLogsMode = false;
|
||||
currentLogPath = null;
|
||||
currentLogServiceName = null;
|
||||
logsPaused = false;
|
||||
}
|
||||
|
||||
// ===== SSE LOG STREAMING =====
|
||||
|
||||
function startLogStreaming(containerId) {
|
||||
if (logEventSource) {
|
||||
stopLogStreaming();
|
||||
}
|
||||
|
||||
const streamBtn = document.getElementById('logs-stream');
|
||||
const pauseBtn = document.getElementById('logs-pause');
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
// Stop interval-based refresh
|
||||
if (logsInterval) {
|
||||
clearInterval(logsInterval);
|
||||
logsInterval = null;
|
||||
}
|
||||
|
||||
try {
|
||||
logEventSource = new EventSource(`/api/v1/logs/stream/${containerId}`);
|
||||
isStreaming = true;
|
||||
|
||||
streamBtn.classList.add('active');
|
||||
streamBtn.textContent = '🔴 Live';
|
||||
streamBtn.title = 'Streaming - click to stop';
|
||||
pauseBtn.style.display = 'none';
|
||||
|
||||
// Add streaming indicator to header
|
||||
const title = document.getElementById('logs-title');
|
||||
if (!title.textContent.includes('🔴')) {
|
||||
title.innerHTML = title.textContent.replace('📋', '📋 🔴');
|
||||
}
|
||||
|
||||
logEventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('Stream error:', data.error);
|
||||
stopLogStreaming();
|
||||
return;
|
||||
}
|
||||
|
||||
// Append new log entry
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'log-entry';
|
||||
entry.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;';
|
||||
|
||||
const streamType = data.stream || 'stdout';
|
||||
const isError = streamType === 'stderr';
|
||||
const levelColor = isError ? 'var(--bad-fg)' : 'var(--fg)';
|
||||
const bgColor = isError ? 'var(--bad-bg)' : 'var(--ok-bg)';
|
||||
const levelBadge = `<span style="background: ${bgColor}; color: ${levelColor}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${isError ? 'STDERR' : 'STDOUT'}</span>`;
|
||||
|
||||
entry.innerHTML = `
|
||||
<div style="flex-shrink: 0;">${levelBadge}</div>
|
||||
<div style="flex: 1; color: ${levelColor}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(data.text)}</div>
|
||||
`;
|
||||
|
||||
logsContent.appendChild(entry);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logsContent.scrollTop = logsContent.scrollHeight;
|
||||
|
||||
// Limit entries to prevent memory issues (keep last 500)
|
||||
while (logsContent.children.length > 500) {
|
||||
logsContent.removeChild(logsContent.firstChild);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing stream data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
logEventSource.onerror = (err) => {
|
||||
console.error('EventSource error:', err);
|
||||
stopLogStreaming();
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to start streaming:', err);
|
||||
stopLogStreaming();
|
||||
}
|
||||
}
|
||||
|
||||
function stopLogStreaming() {
|
||||
if (logEventSource) {
|
||||
logEventSource.close();
|
||||
logEventSource = null;
|
||||
}
|
||||
isStreaming = false;
|
||||
|
||||
const streamBtn = document.getElementById('logs-stream');
|
||||
const pauseBtn = document.getElementById('logs-pause');
|
||||
const title = document.getElementById('logs-title');
|
||||
|
||||
if (streamBtn) {
|
||||
streamBtn.classList.remove('active');
|
||||
streamBtn.textContent = '📡 Live';
|
||||
streamBtn.title = 'Enable real-time streaming';
|
||||
}
|
||||
|
||||
if (pauseBtn) {
|
||||
pauseBtn.style.display = '';
|
||||
}
|
||||
|
||||
if (title) {
|
||||
title.textContent = title.textContent.replace(' 🔴', '');
|
||||
}
|
||||
|
||||
// Restart interval-based refresh if container logs modal is open
|
||||
if (containerLogsMode && currentContainerId && !logsInterval) {
|
||||
logsInterval = setInterval(updateContainerLogsDisplay, DC.POLL.LOGS);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CONTAINER LOGS =====
|
||||
|
||||
async function fetchContainerLogs(containerId, lines = 100) {
|
||||
try {
|
||||
const endpoint = `/api/v1/logs/container/${containerId}?tail=${lines}×tamps=true`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.logs) {
|
||||
return {
|
||||
logs: result.logs,
|
||||
count: result.count,
|
||||
containerName: result.containerName,
|
||||
containerId: result.containerId
|
||||
};
|
||||
} else {
|
||||
return { error: result.error || 'Failed to fetch container logs' };
|
||||
}
|
||||
} else {
|
||||
return { error: `HTTP ${response.status}: ${response.statusText}` };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Container logs fetch failed:', error);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function renderContainerLogEntry(log) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
div.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;';
|
||||
|
||||
const streamColor = log.stream === 'stderr' ? 'var(--bad-fg)' : 'var(--fg)';
|
||||
const streamBadge = log.stream === 'stderr' ?
|
||||
'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>' :
|
||||
'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="flex-shrink: 0;">${streamBadge}</div>
|
||||
<div style="flex: 1; color: ${streamColor}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(log.text)}</div>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
async function updateContainerLogsDisplay() {
|
||||
if (logsPaused || !currentContainerId || !containerLogsMode) return;
|
||||
|
||||
const lines = parseInt(document.getElementById('log-lines').value);
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
try {
|
||||
const result = await fetchContainerLogs(currentContainerId, lines);
|
||||
|
||||
if (result.error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||||
<div>${result.error}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add header row
|
||||
logsContent.innerHTML = `
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex-shrink: 0; width: 80px;">Stream</span>
|
||||
<span style="flex: 1;">Log Output</span>
|
||||
</div>`;
|
||||
|
||||
if (result.logs && result.logs.length > 0) {
|
||||
result.logs.forEach(log => {
|
||||
const logElement = renderContainerLogEntry(log);
|
||||
logsContent.appendChild(logElement);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logsContent.scrollTop = logsContent.scrollHeight;
|
||||
} else {
|
||||
logsContent.innerHTML += `
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available for this container
|
||||
</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${error.message}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openContainerLogsModal(containerId, containerName) {
|
||||
currentContainerId = containerId;
|
||||
currentContainerName = containerName;
|
||||
containerLogsMode = true;
|
||||
fileLogsMode = false;
|
||||
logsPaused = false;
|
||||
|
||||
// Stop any existing streaming
|
||||
stopLogStreaming();
|
||||
|
||||
const modal = document.getElementById('logs-modal');
|
||||
const title = document.getElementById('logs-title');
|
||||
const pauseBtn = document.getElementById('logs-pause');
|
||||
const streamBtn = document.getElementById('logs-stream');
|
||||
|
||||
title.textContent = `📋 ${containerName} - Container Logs`;
|
||||
pauseBtn.textContent = '⏸️ Pause';
|
||||
pauseBtn.classList.remove('paused');
|
||||
|
||||
// Show stream button for container logs
|
||||
if (streamBtn) {
|
||||
streamBtn.style.display = '';
|
||||
}
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
// Initial load
|
||||
updateContainerLogsDisplay();
|
||||
|
||||
// Start auto-refresh every 3 seconds
|
||||
logsInterval = setInterval(updateContainerLogsDisplay, DC.POLL.LOGS);
|
||||
}
|
||||
|
||||
// ===== FILE-BASED LOGS (for native apps) =====
|
||||
|
||||
async function fetchFileLogs(logPath, lines = 100) {
|
||||
try {
|
||||
const endpoint = `/api/v1/logs/file?path=${encodeURIComponent(logPath)}&tail=${lines}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.logs) {
|
||||
return {
|
||||
logs: result.logs,
|
||||
count: result.count,
|
||||
logPath: result.logPath,
|
||||
totalLines: result.totalLines
|
||||
};
|
||||
} else {
|
||||
return { error: result.error || 'Failed to fetch file logs' };
|
||||
}
|
||||
} else {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
return { error: result.error || `HTTP ${response.status}` };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File logs fetch failed:', error);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileLogEntry(log) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
div.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;';
|
||||
|
||||
const text = log.text;
|
||||
let logLevel = 'INFO';
|
||||
let levelColor = 'var(--fg)';
|
||||
|
||||
if (text.match(/ERROR|FATAL|CRITICAL/i)) {
|
||||
logLevel = 'ERROR';
|
||||
levelColor = 'var(--bad-fg)';
|
||||
} else if (text.match(/WARN|WARNING/i)) {
|
||||
logLevel = 'WARN';
|
||||
levelColor = '#f39c12';
|
||||
} else if (text.match(/DEBUG/i)) {
|
||||
logLevel = 'DEBUG';
|
||||
levelColor = 'var(--muted)';
|
||||
}
|
||||
|
||||
const bgColor = levelColor === 'var(--bad-fg)' ? 'var(--bad-bg)' : 'var(--ok-bg)';
|
||||
const levelBadge = `<span style="background: ${bgColor}; color: ${levelColor}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${logLevel}</span>`;
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="flex-shrink: 0;">${levelBadge}</div>
|
||||
<div style="flex: 1; color: ${levelColor}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(text)}</div>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
async function updateFileLogsDisplay() {
|
||||
if (logsPaused || !currentLogPath || !fileLogsMode) return;
|
||||
|
||||
const lines = parseInt(document.getElementById('log-lines').value);
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
try {
|
||||
const result = await fetchFileLogs(currentLogPath, lines);
|
||||
|
||||
if (result.error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
|
||||
<div>${result.error}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
logsContent.innerHTML = `
|
||||
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span style="flex: 1;">Log Output (${result.count} of ${result.totalLines} lines)</span>
|
||||
</div>`;
|
||||
|
||||
if (result.logs && result.logs.length > 0) {
|
||||
result.logs.forEach(log => {
|
||||
const logElement = renderFileLogEntry(log);
|
||||
logsContent.appendChild(logElement);
|
||||
});
|
||||
logsContent.scrollTop = logsContent.scrollHeight;
|
||||
} else {
|
||||
logsContent.innerHTML += `
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available in this file
|
||||
</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
logsContent.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${error.message}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function openFileLogsModal(logPath, serviceName) {
|
||||
currentLogPath = logPath;
|
||||
currentLogServiceName = serviceName;
|
||||
fileLogsMode = true;
|
||||
containerLogsMode = false;
|
||||
logsPaused = false;
|
||||
|
||||
const modal = document.getElementById('logs-modal');
|
||||
const title = document.getElementById('logs-title');
|
||||
const pauseBtn = document.getElementById('logs-pause');
|
||||
const streamBtn = document.getElementById('logs-stream');
|
||||
|
||||
title.textContent = `📋 ${serviceName} - Application Logs`;
|
||||
pauseBtn.textContent = '⏸️ Pause';
|
||||
pauseBtn.classList.remove('paused');
|
||||
|
||||
// Hide stream button for file logs (only available for container logs)
|
||||
if (streamBtn) {
|
||||
streamBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
modal.classList.add('show');
|
||||
|
||||
updateFileLogsDisplay();
|
||||
logsInterval = setInterval(updateFileLogsDisplay, DC.POLL.LOGS);
|
||||
}
|
||||
|
||||
// ===== EVENT LISTENERS =====
|
||||
|
||||
// DNS log buttons — event delegation for dynamic cards
|
||||
document.querySelector('.top')?.addEventListener('click', (e) => {
|
||||
const logsBtn = e.target.closest('[id$="-logs"]');
|
||||
if (!logsBtn) return;
|
||||
const dnsId = logsBtn.id.replace('-logs', '');
|
||||
if (!SITE.dnsServers[dnsId]) return;
|
||||
openLogsModal(dnsId);
|
||||
});
|
||||
|
||||
document.getElementById('logs-close')?.addEventListener('click', closeLogsModal);
|
||||
|
||||
document.getElementById('logs-pause')?.addEventListener('click', () => {
|
||||
logsPaused = !logsPaused;
|
||||
const pauseBtn = document.getElementById('logs-pause');
|
||||
|
||||
if (logsPaused) {
|
||||
pauseBtn.textContent = '▶️ Resume';
|
||||
pauseBtn.classList.add('paused');
|
||||
} else {
|
||||
pauseBtn.textContent = '⏸️ Pause';
|
||||
pauseBtn.classList.remove('paused');
|
||||
updateLogsDisplay();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('log-lines')?.addEventListener('change', () => {
|
||||
if (!logsPaused) {
|
||||
updateLogsDisplay();
|
||||
}
|
||||
});
|
||||
|
||||
// Stream button for real-time SSE logs (only works with container logs)
|
||||
document.getElementById('logs-stream')?.addEventListener('click', () => {
|
||||
if (!containerLogsMode || !currentContainerId) {
|
||||
// Streaming only available for container logs
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
stopLogStreaming();
|
||||
} else {
|
||||
startLogStreaming(currentContainerId);
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on outside click
|
||||
document.getElementById('logs-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'logs-modal') {
|
||||
closeLogsModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close logs-modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (document.getElementById('logs-modal')?.classList.contains('show')) {
|
||||
closeLogsModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ===== EXPORTS =====
|
||||
window.openContainerLogsModal = openContainerLogsModal;
|
||||
window.openFileLogsModal = openFileLogsModal;
|
||||
window.openLogsModal = openLogsModal;
|
||||
|
||||
})();
|
||||
648
status/js/core/service-create.js
Normal file
648
status/js/core/service-create.js
Normal file
@@ -0,0 +1,648 @@
|
||||
// ========== SERVICE CREATION ==========
|
||||
// Add service modal, local/external service creation flows, and event wiring.
|
||||
(function () {
|
||||
|
||||
// ===== SUBDOMAIN AUTO-DERIVE =====
|
||||
|
||||
function deriveSubdomain(name) {
|
||||
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function getSmartSslDefault() {
|
||||
return SITE.defaults?.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'caddy-managed');
|
||||
}
|
||||
|
||||
// ===== SERVICE PREVIEW =====
|
||||
|
||||
function updateServicePreview() {
|
||||
const subdomain = document.getElementById('service-subdomain-input').value || 'subdomain';
|
||||
const ip = document.getElementById('service-ip-input').value || QUICK_IPS.lan || 'localhost';
|
||||
const port = document.getElementById('service-port-input').value || DC.DEFAULTS.SERVICE_PORT;
|
||||
const sslType = document.getElementById('ssl-type-select').value;
|
||||
const caName = document.getElementById('ca-name-input').value || 'sami-ca';
|
||||
const existingCa = document.getElementById('existing-ca-select').value;
|
||||
const enableAuth = document.getElementById('enable-auth').checked;
|
||||
const enableCors = document.getElementById('enable-cors').checked;
|
||||
const customHeaders = document.getElementById('custom-headers-input').value;
|
||||
const upstreamPath = document.getElementById('upstream-path-input').value || '/';
|
||||
const healthCheck = document.getElementById('health-check-input').value;
|
||||
const timeout = document.getElementById('timeout-input').value || 30;
|
||||
|
||||
const dnsPreview = document.getElementById('dns-preview');
|
||||
if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`;
|
||||
|
||||
const urlPreview = document.getElementById('url-preview');
|
||||
if (urlPreview) urlPreview.textContent = `https://${buildDomain(subdomain)}`;
|
||||
|
||||
const config = {
|
||||
subdomain, port, ip, sslType, caName, existingCa,
|
||||
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout
|
||||
};
|
||||
|
||||
const caddyConfig = window.generateCaddyConfig(config);
|
||||
const configPreview = document.getElementById('caddy-config-preview');
|
||||
if (configPreview) configPreview.value = caddyConfig;
|
||||
}
|
||||
|
||||
// ===== QUICK IP CONFIGURATION =====
|
||||
|
||||
const QUICK_IPS = {
|
||||
localhost: '127.0.0.1',
|
||||
lan: '',
|
||||
tailscale: ''
|
||||
};
|
||||
|
||||
async function detectNetworkIPs() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/network/ips', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.lan) QUICK_IPS.lan = data.lan;
|
||||
if (data.tailscale) QUICK_IPS.tailscale = data.tailscale;
|
||||
}
|
||||
} catch (e) {
|
||||
// API not available
|
||||
}
|
||||
|
||||
const lanBtn = document.getElementById('quick-ip-lan');
|
||||
const tsBtn = document.getElementById('quick-ip-tailscale');
|
||||
if (lanBtn) {
|
||||
if (QUICK_IPS.lan) {
|
||||
lanBtn.dataset.ip = QUICK_IPS.lan;
|
||||
lanBtn.textContent = `LAN (${QUICK_IPS.lan})`;
|
||||
lanBtn.title = `LAN IP: ${QUICK_IPS.lan}`;
|
||||
} else {
|
||||
lanBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (tsBtn) {
|
||||
if (QUICK_IPS.tailscale) {
|
||||
tsBtn.dataset.ip = QUICK_IPS.tailscale;
|
||||
tsBtn.textContent = `Tailscale (${QUICK_IPS.tailscale})`;
|
||||
tsBtn.title = `Tailscale IP: ${QUICK_IPS.tailscale}`;
|
||||
} else {
|
||||
tsBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
const ipInput = document.getElementById('service-ip-input');
|
||||
if (ipInput && !ipInput.value && QUICK_IPS.lan) ipInput.value = QUICK_IPS.lan;
|
||||
}
|
||||
|
||||
function initQuickIPButtons() {
|
||||
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const ip = btn.dataset.ip;
|
||||
if (ip) {
|
||||
document.getElementById('service-ip-input').value = ip;
|
||||
document.querySelectorAll('.quick-ip-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
updateServicePreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('service-ip-input')?.addEventListener('input', (e) => {
|
||||
const currentIP = e.target.value;
|
||||
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.ip === currentIP);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== ADD SERVICE MODAL =====
|
||||
|
||||
async function openAddServiceModal() {
|
||||
const modal = document.getElementById('add-service-modal');
|
||||
modal.classList.add('show');
|
||||
|
||||
const modalContent = modal.querySelector('.weather-modal-content');
|
||||
if (modalContent) modalContent.scrollTop = 0;
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Set smart SSL default
|
||||
const sslSelect = document.getElementById('ssl-type-select');
|
||||
if (sslSelect) sslSelect.value = getSmartSslDefault();
|
||||
|
||||
await detectNetworkIPs();
|
||||
|
||||
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
|
||||
await window.loadExistingCAs(caddyfilePath);
|
||||
|
||||
// Check Tailscale status
|
||||
const tailscaleStatus = document.getElementById('manual-tailscale-status');
|
||||
const tailscaleCheckbox = document.getElementById('manual-tailscale-only');
|
||||
try {
|
||||
const response = await fetch('/api/v1/tailscale/status');
|
||||
const data = await response.json();
|
||||
if (data.success && data.installed && data.connected) {
|
||||
tailscaleStatus.innerHTML = `
|
||||
<span style="color: #4caf50;">\u2713 Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 6px;">${data.self?.hostname} (${data.self?.ip})</span>
|
||||
`;
|
||||
tailscaleCheckbox.disabled = false;
|
||||
} else if (data.installed) {
|
||||
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">\u26A0 Not connected</span>`;
|
||||
tailscaleCheckbox.disabled = true;
|
||||
} else {
|
||||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Not available</span>`;
|
||||
tailscaleCheckbox.disabled = true;
|
||||
}
|
||||
} catch (e) {
|
||||
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check</span>`;
|
||||
tailscaleCheckbox.disabled = true;
|
||||
}
|
||||
tailscaleCheckbox.checked = false;
|
||||
|
||||
updateServicePreview();
|
||||
}
|
||||
|
||||
// ===== SERVICE TYPE SWITCHING (TAB STYLE) =====
|
||||
|
||||
function setupServiceTypeSwitching() {
|
||||
const localRadio = document.getElementById('service-type-local');
|
||||
const externalRadio = document.getElementById('service-type-external');
|
||||
const localConfig = document.getElementById('local-service-config');
|
||||
const externalConfig = document.getElementById('external-service-config');
|
||||
const tabLocal = document.getElementById('tab-local');
|
||||
const tabExternal = document.getElementById('tab-external');
|
||||
|
||||
function switchServiceType() {
|
||||
if (localRadio.checked) {
|
||||
localConfig.style.display = 'grid';
|
||||
externalConfig.style.display = 'none';
|
||||
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
|
||||
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
|
||||
} else {
|
||||
localConfig.style.display = 'none';
|
||||
externalConfig.style.display = 'block';
|
||||
if (tabExternal) { tabExternal.style.background = 'var(--accent)'; tabExternal.style.color = 'var(--bg)'; }
|
||||
if (tabLocal) { tabLocal.style.background = 'transparent'; tabLocal.style.color = 'var(--muted)'; }
|
||||
}
|
||||
}
|
||||
|
||||
localRadio?.addEventListener('change', switchServiceType);
|
||||
externalRadio?.addEventListener('change', switchServiceType);
|
||||
}
|
||||
|
||||
// ===== AUTO-DERIVE SUBDOMAIN FROM NAME =====
|
||||
|
||||
function setupAutoSubdomain() {
|
||||
// Local service: name → subdomain + preview
|
||||
const nameInput = document.getElementById('service-name-input');
|
||||
const subdomainInput = document.getElementById('service-subdomain-input');
|
||||
const subdomainPreview = document.getElementById('subdomain-preview');
|
||||
let userEditedSubdomain = false;
|
||||
|
||||
nameInput?.addEventListener('input', () => {
|
||||
const derived = deriveSubdomain(nameInput.value);
|
||||
if (!userEditedSubdomain && subdomainInput) {
|
||||
subdomainInput.value = derived;
|
||||
}
|
||||
if (subdomainPreview) {
|
||||
subdomainPreview.textContent = derived ? `\u2192 ${buildDomain(derived)}` : '';
|
||||
}
|
||||
updateServicePreview();
|
||||
});
|
||||
|
||||
subdomainInput?.addEventListener('input', () => {
|
||||
userEditedSubdomain = subdomainInput.value !== deriveSubdomain(nameInput?.value || '');
|
||||
const sub = subdomainInput.value.trim() || deriveSubdomain(nameInput?.value || '');
|
||||
if (subdomainPreview) {
|
||||
subdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
|
||||
}
|
||||
updateServicePreview();
|
||||
});
|
||||
|
||||
// External service: name → subdomain + preview
|
||||
const extNameInput = document.getElementById('external-service-name');
|
||||
const extSubdomainInput = document.getElementById('external-service-subdomain');
|
||||
const extSubdomainPreview = document.getElementById('external-subdomain-preview');
|
||||
const extDomainPreview = document.getElementById('external-domain-preview');
|
||||
let userEditedExtSubdomain = false;
|
||||
|
||||
extNameInput?.addEventListener('input', () => {
|
||||
const derived = deriveSubdomain(extNameInput.value);
|
||||
if (!userEditedExtSubdomain && extSubdomainInput) {
|
||||
extSubdomainInput.value = derived;
|
||||
}
|
||||
const sub = extSubdomainInput?.value || derived;
|
||||
if (extSubdomainPreview) {
|
||||
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
|
||||
}
|
||||
if (extDomainPreview) {
|
||||
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
|
||||
}
|
||||
});
|
||||
|
||||
extSubdomainInput?.addEventListener('input', () => {
|
||||
userEditedExtSubdomain = extSubdomainInput.value !== deriveSubdomain(extNameInput?.value || '');
|
||||
const sub = extSubdomainInput.value.trim() || deriveSubdomain(extNameInput?.value || '');
|
||||
if (extSubdomainPreview) {
|
||||
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
|
||||
}
|
||||
if (extDomainPreview) {
|
||||
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== CREATE EXTERNAL SERVICE =====
|
||||
|
||||
async function createExternalService() {
|
||||
const name = document.getElementById('external-service-name').value.trim();
|
||||
const externalUrl = document.getElementById('external-service-url').value.trim();
|
||||
const subdomain = (document.getElementById('external-service-subdomain').value.trim() || deriveSubdomain(name)).toLowerCase();
|
||||
const logo = document.getElementById('external-service-logo').value.trim();
|
||||
const icon = document.getElementById('external-service-icon').value.trim();
|
||||
const createDns = document.getElementById('external-create-dns').checked;
|
||||
const createCaddy = document.getElementById('external-create-caddy').checked;
|
||||
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
||||
const preserveHost = document.getElementById('external-preserve-host').checked;
|
||||
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
||||
|
||||
if (!name || !externalUrl) {
|
||||
showNotification('Please fill in Name and External URL', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subdomain) {
|
||||
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!externalUrl.startsWith('http://') && !externalUrl.startsWith('https://')) {
|
||||
showNotification('External URL must start with http:// or https://', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = buildDomain(subdomain);
|
||||
|
||||
try {
|
||||
const results = { dns: null, caddy: null, dashboard: false };
|
||||
|
||||
if (createDns) {
|
||||
const adminToken = window.getToken('dns2', 'admin');
|
||||
if (adminToken) {
|
||||
try {
|
||||
const dnsResponse = await secureFetch('/api/v1/dns/record', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: domain,
|
||||
ip: proxyIp,
|
||||
ttl: DC.DEFAULTS.TTL,
|
||||
server: SITE.dnsIp
|
||||
})
|
||||
});
|
||||
const dnsResult = await dnsResponse.json();
|
||||
results.dns = dnsResult.success ? 'created' : (dnsResult.error || 'failed');
|
||||
} catch (e) {
|
||||
results.dns = e.message;
|
||||
}
|
||||
} else {
|
||||
results.dns = 'no admin token (configure in \uD83D\uDD11 Tokens)';
|
||||
}
|
||||
}
|
||||
|
||||
if (createCaddy) {
|
||||
try {
|
||||
const caddyConfig = {
|
||||
subdomain: subdomain,
|
||||
externalUrl: externalUrl,
|
||||
preserveHost: preserveHost,
|
||||
followRedirects: followRedirects,
|
||||
sslType: 'caddy-managed',
|
||||
caddyfilePath: DC.DEFAULTS.CADDYFILE,
|
||||
reloadCaddy: true
|
||||
};
|
||||
|
||||
const caddyResponse = await secureFetch('/api/v1/site/external', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(caddyConfig)
|
||||
});
|
||||
|
||||
const caddyResult = await caddyResponse.json();
|
||||
results.caddy = caddyResult.success ? 'created' : (caddyResult.error || 'failed');
|
||||
} catch (e) {
|
||||
results.caddy = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
const newService = {
|
||||
id: subdomain,
|
||||
name: name,
|
||||
url: `https://${domain}`,
|
||||
externalUrl: externalUrl,
|
||||
logo: logo || icon || '\uD83C\uDF10',
|
||||
isExternal: true,
|
||||
isCustom: true
|
||||
};
|
||||
|
||||
window.APPS.push(newService);
|
||||
results.dashboard = true;
|
||||
|
||||
const defaultServices = ['plex', 'router', 'chat', 'sync', 'torrent', 'radarr', 'sonarr', 'prowlarr', 'portainer', 'requests', 'jellyfin', 'emby'];
|
||||
const customServices = window.APPS.filter(app => !defaultServices.includes(app.id));
|
||||
safeSet('custom-services', JSON.stringify(customServices));
|
||||
|
||||
try {
|
||||
await secureFetch('/api/v1/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(window.APPS)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to save to services.json:', e);
|
||||
}
|
||||
|
||||
window.buildGrid();
|
||||
window.refreshAll();
|
||||
|
||||
closeAddServiceModal();
|
||||
|
||||
const parts = [`External service "${name}" added!`];
|
||||
if (createDns) parts.push(`DNS: ${results.dns === 'created' ? '\u2713' : '\u26A0 ' + results.dns}`);
|
||||
if (createCaddy) parts.push(`Caddy: ${results.caddy === 'created' ? '\u2713' : '\u26A0 ' + results.caddy}`);
|
||||
parts.push(`Access at: https://${domain}`);
|
||||
showNotification(parts.join(' | '), 'success', 6000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create external service:', error);
|
||||
showNotification(`Failed to create external service: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CLOSE ADD SERVICE MODAL =====
|
||||
|
||||
function closeAddServiceModal() {
|
||||
closeModal('add-service-modal');
|
||||
|
||||
document.body.style.overflow = '';
|
||||
|
||||
document.getElementById('service-name-input').value = '';
|
||||
document.getElementById('service-subdomain-input').value = '';
|
||||
document.getElementById('service-port-input').value = '';
|
||||
document.getElementById('service-ip-input').value = QUICK_IPS.lan || '';
|
||||
document.getElementById('service-logo-input').value = '';
|
||||
document.getElementById('dns-ttl-input').value = DC.DEFAULTS.TTL;
|
||||
document.getElementById('ssl-type-select').value = getSmartSslDefault();
|
||||
document.getElementById('ca-name-input').value = '';
|
||||
document.getElementById('enable-auth').checked = false;
|
||||
document.getElementById('enable-cors').checked = false;
|
||||
document.getElementById('custom-headers-input').value = '';
|
||||
document.getElementById('upstream-path-input').value = '/';
|
||||
document.getElementById('health-check-input').value = '';
|
||||
document.getElementById('timeout-input').value = '30';
|
||||
|
||||
// Clear subdomain previews
|
||||
const subPrev = document.getElementById('subdomain-preview');
|
||||
if (subPrev) subPrev.textContent = '';
|
||||
const extSubPrev = document.getElementById('external-subdomain-preview');
|
||||
if (extSubPrev) extSubPrev.textContent = '';
|
||||
|
||||
// Clear external fields
|
||||
const extName = document.getElementById('external-service-name');
|
||||
if (extName) extName.value = '';
|
||||
const extSub = document.getElementById('external-service-subdomain');
|
||||
if (extSub) extSub.value = '';
|
||||
const extUrl = document.getElementById('external-service-url');
|
||||
if (extUrl) extUrl.value = '';
|
||||
const extLogo = document.getElementById('external-service-logo');
|
||||
if (extLogo) extLogo.value = '';
|
||||
const extIcon = document.getElementById('external-service-icon');
|
||||
if (extIcon) extIcon.value = '';
|
||||
|
||||
// Collapse options
|
||||
const localOpts = document.getElementById('local-advanced-options');
|
||||
if (localOpts) localOpts.removeAttribute('open');
|
||||
const extOpts = document.getElementById('external-advanced-options');
|
||||
if (extOpts) extOpts.removeAttribute('open');
|
||||
|
||||
// Reset to local tab
|
||||
const localRadio = document.getElementById('service-type-local');
|
||||
if (localRadio) localRadio.checked = true;
|
||||
const localConfig = document.getElementById('local-service-config');
|
||||
const externalConfig = document.getElementById('external-service-config');
|
||||
if (localConfig) localConfig.style.display = 'grid';
|
||||
if (externalConfig) externalConfig.style.display = 'none';
|
||||
const tabLocal = document.getElementById('tab-local');
|
||||
const tabExternal = document.getElementById('tab-external');
|
||||
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
|
||||
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
|
||||
}
|
||||
|
||||
// ===== CREATE NEW SERVICE =====
|
||||
|
||||
async function createNewService() {
|
||||
const name = document.getElementById('service-name-input').value.trim();
|
||||
const subdomain = (document.getElementById('service-subdomain-input').value.trim() || deriveSubdomain(name)).toLowerCase();
|
||||
const port = document.getElementById('service-port-input').value.trim();
|
||||
const ip = document.getElementById('service-ip-input').value.trim();
|
||||
const logo = document.getElementById('service-logo-input').value.trim();
|
||||
const createDns = document.getElementById('create-dns-record').checked;
|
||||
const ttl = parseInt(document.getElementById('dns-ttl-input').value) || DC.DEFAULTS.TTL;
|
||||
const tailscaleOnly = document.getElementById('manual-tailscale-only')?.checked || false;
|
||||
|
||||
const sslType = document.getElementById('ssl-type-select')?.value || 'caddy-managed';
|
||||
const caName = document.getElementById('ca-name-input')?.value || '';
|
||||
const existingCa = document.getElementById('existing-ca-select')?.value || '';
|
||||
const enableAuth = document.getElementById('enable-auth')?.checked || false;
|
||||
const enableCors = document.getElementById('enable-cors')?.checked || false;
|
||||
const customHeaders = document.getElementById('custom-headers-input')?.value || '';
|
||||
const upstreamPath = document.getElementById('upstream-path-input')?.value || '/';
|
||||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||||
const timeout = document.getElementById('timeout-input')?.value || 30;
|
||||
|
||||
const dnsToken = window.getToken('dns2', 'admin');
|
||||
|
||||
if (!name || !port || !ip) {
|
||||
showNotification('Please fill in Name, Port, and IP Address', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subdomain) {
|
||||
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (createDns && !dnsToken) {
|
||||
showNotification('DNS Admin token required. Configure it in the Tokens menu first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = { dns: null, caddy: null, dashboard: false };
|
||||
|
||||
try {
|
||||
if (createDns) {
|
||||
try {
|
||||
await window.createDnsRecord(subdomain, ip, ttl);
|
||||
results.dns = 'created';
|
||||
} catch (error) {
|
||||
console.error('DNS creation failed:', error);
|
||||
results.dns = error.message;
|
||||
throw new Error(`DNS creation failed: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
results.dns = 'skipped';
|
||||
}
|
||||
|
||||
const caddyConfig = window.generateCaddyConfig({
|
||||
subdomain, port, ip, sslType, caName, existingCa,
|
||||
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout, tailscaleOnly
|
||||
});
|
||||
|
||||
try {
|
||||
const caddyResponse = await secureFetch('/api/v1/site', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: buildDomain(subdomain),
|
||||
upstream: `${ip}:${port}`,
|
||||
config: caddyConfig
|
||||
})
|
||||
});
|
||||
|
||||
const caddyResult = await caddyResponse.json();
|
||||
if (caddyResult.success) {
|
||||
results.caddy = 'added & reloaded';
|
||||
} else {
|
||||
console.error('Caddy configuration failed:', caddyResult.error);
|
||||
results.caddy = caddyResult.error || 'failed';
|
||||
throw new Error(`Caddy configuration failed: ${caddyResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Caddy API error:', error);
|
||||
results.caddy = error.message;
|
||||
throw new Error(`Caddy API error: ${error.message}`);
|
||||
}
|
||||
|
||||
const serviceConfig = {
|
||||
name, subdomain, port, ip,
|
||||
logo: logo || `/assets/${subdomain}.png`,
|
||||
tailscaleOnly: tailscaleOnly || false
|
||||
};
|
||||
|
||||
await window.addServiceToConfig(serviceConfig);
|
||||
results.dashboard = true;
|
||||
|
||||
const statusParts = [
|
||||
`DNS: ${results.dns === 'created' ? '\u2713' : results.dns === 'skipped' ? '\u25CB' : '\u2717'}`,
|
||||
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
|
||||
`Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}`
|
||||
];
|
||||
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 https://${buildDomain(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
|
||||
|
||||
closeAddServiceModal();
|
||||
|
||||
window.buildGrid();
|
||||
window.refreshAll();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating service:', error);
|
||||
showNotification(`Error creating "${name}": ${error.message}`, 'error', 6000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== EVENT LISTENERS =====
|
||||
|
||||
document.getElementById('add-service')?.addEventListener('click', openAddServiceModal);
|
||||
document.getElementById('add-service-cancel')?.addEventListener('click', closeAddServiceModal);
|
||||
document.getElementById('add-service-create')?.addEventListener('click', () => {
|
||||
const serviceType = document.querySelector('input[name="service-type"]:checked')?.value;
|
||||
if (serviceType === 'external') {
|
||||
createExternalService();
|
||||
} else {
|
||||
createNewService();
|
||||
}
|
||||
});
|
||||
|
||||
setupServiceTypeSwitching();
|
||||
setupAutoSubdomain();
|
||||
initQuickIPButtons();
|
||||
|
||||
// SSL type change handler
|
||||
document.getElementById('ssl-type-select')?.addEventListener('change', (e) => {
|
||||
const existingCaConfig = document.getElementById('existing-ca-config');
|
||||
const customCaConfig = document.getElementById('custom-ca-config');
|
||||
|
||||
existingCaConfig.style.display = 'none';
|
||||
customCaConfig.style.display = 'none';
|
||||
|
||||
if (e.target.value === 'existing-ca') {
|
||||
existingCaConfig.style.display = 'block';
|
||||
} else if (e.target.value === 'custom-ca') {
|
||||
customCaConfig.style.display = 'block';
|
||||
}
|
||||
|
||||
updateServicePreview();
|
||||
});
|
||||
|
||||
// Refresh CAs button
|
||||
document.getElementById('refresh-cas')?.addEventListener('click', async () => {
|
||||
const button = document.getElementById('refresh-cas');
|
||||
const originalText = button.textContent;
|
||||
button.textContent = '\u231B Loading...';
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
|
||||
await window.loadExistingCAs(caddyfilePath);
|
||||
button.textContent = '\u2705 Refreshed';
|
||||
} catch (error) {
|
||||
button.textContent = '\u274C Failed';
|
||||
console.error('Failed to refresh CAs:', error);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// DNS record checkbox handler
|
||||
document.getElementById('create-dns-record')?.addEventListener('change', (e) => {
|
||||
const dnsConfig = document.getElementById('dns-config');
|
||||
dnsConfig.style.display = e.target.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Real-time preview updates
|
||||
['service-subdomain-input', 'service-ip-input', 'service-port-input', 'ca-name-input',
|
||||
'existing-ca-select', 'enable-auth', 'enable-cors', 'custom-headers-input', 'upstream-path-input',
|
||||
'health-check-input', 'timeout-input'].forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.addEventListener('input', updateServicePreview);
|
||||
element.addEventListener('change', updateServicePreview);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== CUSTOM SERVICES FROM LOCALSTORAGE =====
|
||||
|
||||
function loadCustomServices() {
|
||||
const customServices = safeGet('custom-services');
|
||||
if (customServices) {
|
||||
try {
|
||||
const services = JSON.parse(customServices);
|
||||
services.forEach(service => {
|
||||
if (!window.APPS.find(app => app.id === service.id)) {
|
||||
window.APPS.push(service);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to load custom services:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCustomServices();
|
||||
|
||||
// ===== WINDOW EXPORTS =====
|
||||
|
||||
window.openAddServiceModal = openAddServiceModal;
|
||||
window.closeAddServiceModal = closeAddServiceModal;
|
||||
|
||||
})();
|
||||
429
status/js/core/service-crud.js
Normal file
429
status/js/core/service-crud.js
Normal file
@@ -0,0 +1,429 @@
|
||||
// ========== SERVICE CRUD ==========
|
||||
// Edit, delete, and update operations for existing services.
|
||||
(function () {
|
||||
|
||||
// ===== SERVICE EDIT MODAL =====
|
||||
let currentEditService = null;
|
||||
|
||||
function openServiceEditModal(service) {
|
||||
currentEditService = service;
|
||||
const modal = document.getElementById('service-edit-modal');
|
||||
|
||||
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
|
||||
document.getElementById('edit-service-name-display').textContent = service.name;
|
||||
document.getElementById('edit-service-url-display').textContent = `https://${buildDomain(service.id)}`;
|
||||
document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
|
||||
document.getElementById('edit-subdomain').value = service.id;
|
||||
document.getElementById('edit-port').value = service.port || '';
|
||||
document.getElementById('edit-ip').value = service.ip || 'localhost';
|
||||
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
||||
document.getElementById('edit-logo-url').value = service.logo || '';
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
function closeServiceEditModal() {
|
||||
closeModal('service-edit-modal');
|
||||
currentEditService = null;
|
||||
}
|
||||
|
||||
async function saveServiceChanges() {
|
||||
if (!currentEditService) return;
|
||||
|
||||
const newSubdomain = document.getElementById('edit-subdomain').value.trim().toLowerCase();
|
||||
const newPort = document.getElementById('edit-port').value.trim();
|
||||
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
||||
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
||||
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
||||
|
||||
if (!newSubdomain) {
|
||||
showNotification('Subdomain is required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldSubdomain = currentEditService.id;
|
||||
const changes = [];
|
||||
|
||||
if (newSubdomain !== oldSubdomain) changes.push('subdomain');
|
||||
if (newPort && newPort !== String(currentEditService.port)) changes.push('port');
|
||||
if (newIp !== currentEditService.ip) changes.push('ip');
|
||||
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
||||
if (newLogo !== currentEditService.logo) changes.push('logo');
|
||||
|
||||
if (changes.length === 0) {
|
||||
closeServiceEditModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('service-edit-save');
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
if (changes.includes('subdomain') || changes.includes('port') || changes.includes('ip') || changes.includes('tailscale')) {
|
||||
const response = await secureFetch('/api/v1/services/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
oldSubdomain,
|
||||
newSubdomain,
|
||||
port: newPort || currentEditService.port,
|
||||
ip: newIp,
|
||||
tailscaleOnly
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update service');
|
||||
}
|
||||
}
|
||||
|
||||
// Update local APPS array
|
||||
const appIndex = window.APPS.findIndex(a => a.id === oldSubdomain);
|
||||
if (appIndex !== -1) {
|
||||
window.APPS[appIndex] = {
|
||||
...window.APPS[appIndex],
|
||||
id: newSubdomain,
|
||||
port: newPort || window.APPS[appIndex].port,
|
||||
ip: newIp,
|
||||
tailscaleOnly,
|
||||
logo: newLogo || window.APPS[appIndex].logo
|
||||
};
|
||||
}
|
||||
|
||||
// Update services via API
|
||||
await secureFetch('/api/v1/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: newSubdomain,
|
||||
name: currentEditService.name,
|
||||
port: newPort || currentEditService.port,
|
||||
ip: newIp,
|
||||
logo: newLogo || currentEditService.logo,
|
||||
tailscaleOnly,
|
||||
containerId: currentEditService.containerId,
|
||||
appTemplate: currentEditService.appTemplate
|
||||
})
|
||||
});
|
||||
|
||||
// If subdomain changed, remove old entry
|
||||
if (newSubdomain !== oldSubdomain) {
|
||||
await secureFetch(`/api/v1/services/${oldSubdomain}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
closeServiceEditModal();
|
||||
window.buildGrid();
|
||||
window.refreshAll();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving service changes:', error);
|
||||
showNotification(`Error saving changes: ${error.message}`, 'error');
|
||||
} finally {
|
||||
saveBtn.textContent = 'Save Changes';
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Logo file upload handler
|
||||
document.getElementById('edit-logo-file')?.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
showNotification('Please select an image file', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const dataUrl = event.target.result;
|
||||
|
||||
document.getElementById('edit-service-logo-preview').src = dataUrl;
|
||||
document.getElementById('edit-logo-url').value = dataUrl;
|
||||
|
||||
if (currentEditService) {
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/assets/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: `${currentEditService.id}.png`,
|
||||
data: dataUrl
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success && result.path) {
|
||||
document.getElementById('edit-logo-url').value = result.path;
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to data URL
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Service edit modal event listeners
|
||||
document.getElementById('service-edit-cancel')?.addEventListener('click', closeServiceEditModal);
|
||||
document.getElementById('service-edit-save')?.addEventListener('click', saveServiceChanges);
|
||||
document.getElementById('service-edit-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'service-edit-modal') closeServiceEditModal();
|
||||
});
|
||||
|
||||
// ===== DELETE SERVICE MODAL =====
|
||||
|
||||
function showDeleteModal(serviceName, hasContainer, containerId) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('delete-service-modal');
|
||||
const title = document.getElementById('delete-modal-title');
|
||||
const message = document.getElementById('delete-modal-message');
|
||||
const containerInfo = document.getElementById('delete-modal-container-info');
|
||||
const containerName = document.getElementById('delete-modal-container-name');
|
||||
const help = document.getElementById('delete-modal-help');
|
||||
const cancelBtn = document.getElementById('delete-modal-cancel');
|
||||
const removeBtn = document.getElementById('delete-modal-remove');
|
||||
const deleteBtn = document.getElementById('delete-modal-delete');
|
||||
|
||||
title.textContent = `Delete "${serviceName}"`;
|
||||
|
||||
if (hasContainer) {
|
||||
message.innerHTML = 'This service has an associated Docker container.<br>Choose how to proceed:';
|
||||
containerInfo.style.display = 'block';
|
||||
containerName.textContent = `Container ID: ${containerId?.slice(0, 12) || 'Unknown'}`;
|
||||
help.style.display = 'block';
|
||||
deleteBtn.style.display = 'block';
|
||||
} else {
|
||||
message.textContent = 'Remove this service from the dashboard?';
|
||||
containerInfo.style.display = 'none';
|
||||
help.style.display = 'none';
|
||||
deleteBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
modal.classList.remove('show');
|
||||
cancelBtn.removeEventListener('click', handleCancel);
|
||||
removeBtn.removeEventListener('click', handleRemove);
|
||||
deleteBtn.removeEventListener('click', handleDelete);
|
||||
modal.removeEventListener('click', handleBackdrop);
|
||||
};
|
||||
|
||||
const handleCancel = () => { cleanup(); resolve(null); };
|
||||
const handleRemove = () => { cleanup(); resolve(false); };
|
||||
const handleDelete = () => { cleanup(); resolve(true); };
|
||||
const handleBackdrop = (e) => { if (e.target === modal) { cleanup(); resolve(null); } };
|
||||
|
||||
cancelBtn.addEventListener('click', handleCancel);
|
||||
removeBtn.addEventListener('click', handleRemove);
|
||||
deleteBtn.addEventListener('click', handleDelete);
|
||||
modal.addEventListener('click', handleBackdrop);
|
||||
|
||||
modal.classList.add('show');
|
||||
});
|
||||
}
|
||||
|
||||
// ===== UPDATE CONTAINER =====
|
||||
|
||||
async function updateContainer(containerId, serviceName, serviceId) {
|
||||
const updateBtn = document.getElementById(`update-btn-${serviceId}`);
|
||||
const originalText = updateBtn?.textContent;
|
||||
|
||||
if (!confirm(`Update ${serviceName} to the latest version?\n\nThis will:\n1. Pull the latest image\n2. Stop the container\n3. Recreate with same settings\n\nThe service will be briefly unavailable.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (updateBtn) {
|
||||
updateBtn.textContent = '\u{1F504}';
|
||||
updateBtn.disabled = true;
|
||||
updateBtn.title = 'Updating...';
|
||||
}
|
||||
|
||||
const response = await secureFetch(`/api/v1/containers/${containerId}/update`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const service = window.APPS.find(app => app.id === serviceId);
|
||||
if (service && result.newContainerId) {
|
||||
service.containerId = result.newContainerId;
|
||||
}
|
||||
|
||||
if (updateBtn) {
|
||||
updateBtn.textContent = '\u{2705}';
|
||||
updateBtn.title = 'Updated successfully!';
|
||||
setTimeout(() => {
|
||||
updateBtn.textContent = originalText;
|
||||
updateBtn.disabled = false;
|
||||
updateBtn.title = 'Update container to latest version';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
setTimeout(() => window.refreshAll(), 2000);
|
||||
showNotification(`${serviceName} updated successfully!`, 'success');
|
||||
} else {
|
||||
throw new Error(result.error || 'Update failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
|
||||
if (updateBtn) {
|
||||
updateBtn.textContent = '\u{274C}';
|
||||
updateBtn.title = 'Update failed';
|
||||
setTimeout(() => {
|
||||
updateBtn.textContent = originalText;
|
||||
updateBtn.disabled = false;
|
||||
updateBtn.title = 'Update container to latest version';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
showNotification(`Failed to update ${serviceName}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== DELETE SERVICE =====
|
||||
|
||||
async function deleteService(serviceId, serviceName) {
|
||||
const service = window.APPS.find(app => app.id === serviceId);
|
||||
const domain = service ? buildDomain(service.id) : null;
|
||||
const hasContainer = service?.containerId;
|
||||
|
||||
const deleteContainer = await showDeleteModal(serviceName || serviceId, hasContainer, service?.containerId);
|
||||
|
||||
if (deleteContainer === null) {
|
||||
return; // User cancelled
|
||||
}
|
||||
|
||||
let results = {
|
||||
dashboard: false,
|
||||
container: null,
|
||||
dns: null,
|
||||
caddy: null,
|
||||
service: null
|
||||
};
|
||||
|
||||
// Full removal with container
|
||||
if (deleteContainer && hasContainer) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
containerId: service.containerId,
|
||||
subdomain: service.id,
|
||||
ip: service.ip || 'localhost',
|
||||
deleteContainer: 'true'
|
||||
});
|
||||
|
||||
const response = await secureFetch(`/api/v1/apps/${encodeURIComponent(service.id)}?${params.toString()}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
results = { ...results, ...result.results, dashboard: false };
|
||||
} else {
|
||||
console.error('App removal failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('App removal error:', error);
|
||||
}
|
||||
} else if (deleteContainer && domain) {
|
||||
// Fallback for manually added services
|
||||
try {
|
||||
const serviceIP = service?.ip || 'localhost';
|
||||
const dnsResponse = await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(domain)}&type=A&ipAddress=${encodeURIComponent(serviceIP)}&server=${SITE.dnsIp}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const dnsResult = await dnsResponse.json();
|
||||
results.dns = dnsResult.success ? 'deleted' : (dnsResult.error || 'failed');
|
||||
} catch (e) {
|
||||
results.dns = e.message;
|
||||
}
|
||||
|
||||
try {
|
||||
const caddyResponse = await secureFetch(`/api/v1/site/${encodeURIComponent(domain)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const caddyResult = await caddyResponse.json();
|
||||
results.caddy = (caddyResult.success || (caddyResult.error && caddyResult.error.includes('not found'))) ? 'removed' : (caddyResult.error || 'failed');
|
||||
} catch (e) {
|
||||
results.caddy = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from APPS array
|
||||
const index = window.APPS.findIndex(app => app.id === serviceId);
|
||||
if (index > -1) {
|
||||
window.APPS.splice(index, 1);
|
||||
results.dashboard = true;
|
||||
}
|
||||
|
||||
// Remove from localStorage
|
||||
try {
|
||||
const customApps = safeGetJSON('custom-apps', []);
|
||||
const localIndex = customApps.findIndex(app => app.id === serviceId);
|
||||
if (localIndex > -1) {
|
||||
customApps.splice(localIndex, 1);
|
||||
safeSet('custom-apps', JSON.stringify(customApps));
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
|
||||
// Remove from services.json via API
|
||||
try {
|
||||
const serviceResponse = await secureFetch(`/api/v1/services/${encodeURIComponent(serviceId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const serviceResult = await serviceResponse.json();
|
||||
results.service = serviceResult.success ? 'removed' : (serviceResult.error || 'failed');
|
||||
} catch (e) {
|
||||
results.service = e.message;
|
||||
}
|
||||
|
||||
window.buildGrid();
|
||||
window.refreshAll();
|
||||
|
||||
// Only show alert if there are actual errors
|
||||
let hasErrors = false;
|
||||
let errorMessages = [];
|
||||
|
||||
if (!results.dashboard) {
|
||||
hasErrors = true;
|
||||
errorMessages.push('\u{2717} Failed to remove from dashboard');
|
||||
}
|
||||
|
||||
const successStates = ['removed', 'already removed', 'not found', 'deleted', 'kept (user choice)', 'skipped', 'no such record', 'does not exist'];
|
||||
const isSuccess = (val) => !val || successStates.some(s => val.toLowerCase().includes(s.toLowerCase()));
|
||||
|
||||
if (results.container && !isSuccess(results.container)) {
|
||||
hasErrors = true;
|
||||
errorMessages.push(`\u{26A0} Container: ${results.container}`);
|
||||
}
|
||||
if (results.dns && !isSuccess(results.dns)) {
|
||||
hasErrors = true;
|
||||
errorMessages.push(`\u{26A0} DNS Record: ${results.dns}`);
|
||||
}
|
||||
if (results.caddy && !isSuccess(results.caddy)) {
|
||||
hasErrors = true;
|
||||
errorMessages.push(`\u{26A0} Caddy Config: ${results.caddy}`);
|
||||
}
|
||||
if (results.service && !isSuccess(results.service)) {
|
||||
hasErrors = true;
|
||||
errorMessages.push(`\u{26A0} Service File: ${results.service}`);
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
showNotification(`Error deleting "${serviceName || serviceId}": ${errorMessages.join(', ')}`, 'error', 6000);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== WINDOW EXPORTS =====
|
||||
|
||||
window.openServiceEditModal = openServiceEditModal;
|
||||
window.showDeleteModal = showDeleteModal;
|
||||
window.updateContainer = updateContainer;
|
||||
window.deleteService = deleteService;
|
||||
|
||||
})();
|
||||
245
status/js/core/service-infrastructure.js
Normal file
245
status/js/core/service-infrastructure.js
Normal file
@@ -0,0 +1,245 @@
|
||||
// ========== SERVICE INFRASTRUCTURE ==========
|
||||
// Caddy config generation, DNS record creation, and service registration.
|
||||
(function () {
|
||||
|
||||
// ===== LOAD EXISTING CAs =====
|
||||
|
||||
async function loadExistingCAs(caddyfilePath) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(caddyfilePath)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load CAs: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
const select = document.getElementById('existing-ca-select');
|
||||
select.innerHTML = '';
|
||||
|
||||
if (result.data.cas.length === 0) {
|
||||
select.innerHTML = '<option value="">No CAs found in Caddyfile</option>';
|
||||
} else {
|
||||
select.innerHTML = '<option value="">Select existing CA...</option>';
|
||||
result.data.cas.forEach(ca => {
|
||||
const option = document.createElement('option');
|
||||
if (typeof ca === 'object') {
|
||||
option.value = ca.id;
|
||||
option.textContent = ca.displayName || ca.name;
|
||||
} else {
|
||||
option.value = ca;
|
||||
option.textContent = ca;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
return result.data.cas;
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading CAs:', error);
|
||||
const select = document.getElementById('existing-ca-select');
|
||||
select.innerHTML = '<option value="">Error loading CAs</option>';
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ===== GENERATE CADDY CONFIG =====
|
||||
|
||||
function generateCaddyConfig(config) {
|
||||
const {
|
||||
subdomain,
|
||||
port,
|
||||
ip,
|
||||
sslType,
|
||||
caName,
|
||||
existingCa,
|
||||
enableAuth,
|
||||
enableCors,
|
||||
customHeaders,
|
||||
upstreamPath,
|
||||
healthCheck,
|
||||
timeout,
|
||||
tailscaleOnly
|
||||
} = config;
|
||||
|
||||
let caddyConfig = `${buildDomain(subdomain)} {\n`;
|
||||
|
||||
// Tailscale-only access restriction
|
||||
if (tailscaleOnly) {
|
||||
caddyConfig += ` @blocked not remote_ip 100.64.0.0/10\n`;
|
||||
caddyConfig += ` respond @blocked "Access denied. Tailscale connection required." 403\n`;
|
||||
}
|
||||
|
||||
// SSL Configuration
|
||||
switch (sslType) {
|
||||
case 'letsencrypt':
|
||||
break;
|
||||
case 'caddy-managed':
|
||||
caddyConfig += ` tls internal\n`;
|
||||
break;
|
||||
case 'existing-ca':
|
||||
if (existingCa) {
|
||||
caddyConfig += ` tls {\n ca ${existingCa}\n }\n`;
|
||||
}
|
||||
break;
|
||||
case 'custom-ca':
|
||||
if (caName) {
|
||||
caddyConfig += ` tls {\n ca ${caName}\n }\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if (enableAuth) {
|
||||
caddyConfig += ` basicauth {\n admin $2a$14$hashed_password_here\n }\n`;
|
||||
}
|
||||
|
||||
// CORS Headers
|
||||
if (enableCors) {
|
||||
caddyConfig += ` header {\n`;
|
||||
caddyConfig += ` Access-Control-Allow-Origin "*"\n`;
|
||||
caddyConfig += ` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"\n`;
|
||||
caddyConfig += ` Access-Control-Allow-Headers "Content-Type, Authorization"\n`;
|
||||
caddyConfig += ` }\n`;
|
||||
}
|
||||
|
||||
// Custom Headers
|
||||
if (customHeaders) {
|
||||
try {
|
||||
const headers = JSON.parse(customHeaders);
|
||||
caddyConfig += ` header {\n`;
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
caddyConfig += ` ${key} "${value}"\n`;
|
||||
});
|
||||
caddyConfig += ` }\n`;
|
||||
} catch (e) {
|
||||
console.warn('Invalid JSON in custom headers');
|
||||
}
|
||||
}
|
||||
|
||||
// Health Check
|
||||
if (healthCheck) {
|
||||
caddyConfig += ` health_uri ${healthCheck}\n`;
|
||||
}
|
||||
|
||||
// Reverse Proxy
|
||||
caddyConfig += ` reverse_proxy ${ip}:${port} {\n`;
|
||||
if (upstreamPath && upstreamPath !== '/') {
|
||||
caddyConfig += ` rewrite ${upstreamPath}\n`;
|
||||
}
|
||||
if (timeout && timeout !== 30) {
|
||||
caddyConfig += ` transport http {\n`;
|
||||
caddyConfig += ` dial_timeout ${timeout}s\n`;
|
||||
caddyConfig += ` response_header_timeout ${timeout}s\n`;
|
||||
caddyConfig += ` }\n`;
|
||||
}
|
||||
caddyConfig += ` }\n`;
|
||||
|
||||
caddyConfig += `}\n`;
|
||||
|
||||
return caddyConfig;
|
||||
}
|
||||
|
||||
// ===== CREATE DNS RECORD =====
|
||||
|
||||
async function createDnsRecord(subdomain, ip, ttl = DC.DEFAULTS.TTL) {
|
||||
const dnsToken = window.getToken('dns2', 'admin');
|
||||
|
||||
if (!dnsToken) {
|
||||
throw new Error('DNS admin token not configured. Please set it in the Tokens menu.');
|
||||
}
|
||||
|
||||
const domain = buildDomain(subdomain);
|
||||
|
||||
const response = await secureFetch('/api/v1/dns/record', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: domain,
|
||||
ip: ip,
|
||||
ttl: ttl,
|
||||
token: dnsToken,
|
||||
server: SITE.dnsIp
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`DNS API Error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(`DNS Error: ${result.error || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== ADD SERVICE TO CONFIG =====
|
||||
|
||||
async function addServiceToConfig(serviceConfig) {
|
||||
const newService = {
|
||||
id: serviceConfig.subdomain,
|
||||
name: serviceConfig.name,
|
||||
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/services', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newService)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to save service');
|
||||
}
|
||||
|
||||
await window.loadServices();
|
||||
window.buildGrid();
|
||||
|
||||
return newService;
|
||||
} catch (error) {
|
||||
console.error('Failed to add service to config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ADD TO CADDYFILE =====
|
||||
|
||||
async function addToCaddyfile(config) {
|
||||
const subdomain = document.getElementById('service-subdomain-input').value.trim();
|
||||
const ip = document.getElementById('service-ip-input').value.trim() || 'localhost';
|
||||
const port = document.getElementById('service-port-input').value.trim() || '80';
|
||||
|
||||
const response = await secureFetch('/api/v1/site', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain: buildDomain(subdomain),
|
||||
upstream: `${ip}:${port}`,
|
||||
config: config
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || `Caddy API Error: ${response.status}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== WINDOW EXPORTS =====
|
||||
|
||||
window.loadExistingCAs = loadExistingCAs;
|
||||
window.generateCaddyConfig = generateCaddyConfig;
|
||||
window.createDnsRecord = createDnsRecord;
|
||||
window.addServiceToConfig = addServiceToConfig;
|
||||
window.addToCaddyfile = addToCaddyfile;
|
||||
|
||||
})();
|
||||
334
status/js/core/service-modals.js
Normal file
334
status/js/core/service-modals.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// ========== SERVICE MODAL TEMPLATES ==========
|
||||
// Injects the HTML for service edit, delete, and add modals into the DOM.
|
||||
// Must load before service-crud.js and service-create.js.
|
||||
(function () {
|
||||
|
||||
injectModal('service-edit-modal', `
|
||||
<div id="service-edit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
|
||||
<h3 id="service-edit-title">Edit Service</h3>
|
||||
|
||||
<div style="display: grid; gap: 16px; margin-top: 16px;">
|
||||
<!-- Service Info -->
|
||||
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--card-bg); border-radius: 8px;">
|
||||
<img id="edit-service-logo-preview" src="" alt="" style="width: 48px; height: 48px; border-radius: 8px; object-fit: contain; background: var(--bg);" />
|
||||
<div>
|
||||
<div id="edit-service-name-display" style="font-weight: 600; font-size: 1.1rem;"></div>
|
||||
<div id="edit-service-url-display" class="text-muted-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subdomain -->
|
||||
<div>
|
||||
<label for="edit-subdomain" class="form-label-accent-sm">
|
||||
Subdomain
|
||||
</label>
|
||||
<div class="flex-row-gap-center">
|
||||
<input type="text" id="edit-subdomain" class="input-flex" />
|
||||
<span id="edit-tld-suffix" style="color: var(--muted);">.home</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Port -->
|
||||
<div>
|
||||
<label for="edit-port" class="form-label-accent-sm">
|
||||
Port
|
||||
</label>
|
||||
<input type="number" id="edit-port" class="form-input-md" />
|
||||
<div class="form-hint-sm">
|
||||
The port Caddy will proxy to (container's exposed port)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Address -->
|
||||
<div>
|
||||
<label for="edit-ip" class="form-label-accent-sm">
|
||||
IP Address
|
||||
</label>
|
||||
<input type="text" id="edit-ip" class="form-input-md" />
|
||||
</div>
|
||||
|
||||
<!-- Tailscale Protection -->
|
||||
<div>
|
||||
<label style="display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="edit-tailscale-only" style="width: 18px; height: 18px;" />
|
||||
<div>
|
||||
<div class="fw-500">Tailscale-Only Access</div>
|
||||
<div class="text-hint">Restrict this service to Tailscale users only</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div>
|
||||
<label class="form-label-accent-sm">
|
||||
Service Logo
|
||||
</label>
|
||||
<div class="flex-row-gap-center">
|
||||
<input type="text" id="edit-logo-url" placeholder="/assets/service.png or https://..." class="input-flex" />
|
||||
<label style="padding: 10px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; white-space: nowrap;">
|
||||
<input type="file" id="edit-logo-file" accept="image/*" style="display: none;" />
|
||||
Upload
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-hint-sm">
|
||||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||
<button id="service-edit-cancel">Cancel</button>
|
||||
<button id="service-edit-save" class="btn-accent">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
injectModal('delete-service-modal', `
|
||||
<div id="delete-service-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 400px; max-width: 500px;">
|
||||
<h3 id="delete-modal-title" class="mb-16">Delete Service</h3>
|
||||
|
||||
<div id="delete-modal-message" style="margin-bottom: 20px; line-height: 1.5;">
|
||||
<!-- Dynamic content -->
|
||||
</div>
|
||||
|
||||
<div id="delete-modal-container-info" style="display: none; margin-bottom: 20px; padding: 12px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 500; margin-bottom: 8px;">Docker Container</div>
|
||||
<div id="delete-modal-container-name" style="font-size: 0.9rem; color: var(--muted);"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button id="delete-modal-cancel" style="padding: 10px 20px;">Cancel</button>
|
||||
<button id="delete-modal-remove" style="padding: 10px 20px; background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">
|
||||
Remove
|
||||
</button>
|
||||
<button id="delete-modal-delete" style="display: none; padding: 10px 20px; background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="delete-modal-help" style="display: none; margin-top: 16px; padding: 12px; background: color-mix(in srgb, var(--muted) 10%, transparent); border-radius: 8px; font-size: 0.85rem; color: var(--muted);">
|
||||
<div><strong>Remove:</strong> Remove from dashboard only (container keeps running)</div>
|
||||
<div style="margin-top: 6px;"><strong>Delete:</strong> Full removal - stops container, removes DNS & Caddy config</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
injectModal('add-service-modal', `
|
||||
<div id="add-service-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>Add Service</h3>
|
||||
|
||||
<!-- Service Type Tabs -->
|
||||
<div style="display: flex; gap: 2px; margin-bottom: 16px; background: var(--card-bg); border-radius: 8px; padding: 3px; border: 1px solid var(--border);">
|
||||
<label id="tab-local" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; background: var(--accent); color: var(--bg); transition: all 0.15s;">
|
||||
<input type="radio" name="service-type" value="local" id="service-type-local" checked style="display: none;" />
|
||||
Local
|
||||
</label>
|
||||
<label id="tab-external" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; color: var(--muted); transition: all 0.15s;">
|
||||
<input type="radio" name="service-type" value="external" id="service-type-external" style="display: none;" />
|
||||
External
|
||||
</label>
|
||||
</div>
|
||||
<span id="service-type-description" style="display: none;"></span>
|
||||
|
||||
<!-- LOCAL SERVICE -->
|
||||
<div id="local-service-config" style="display: grid; gap: 12px;">
|
||||
<div>
|
||||
<label for="service-name-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
|
||||
<input type="text" id="service-name-input" placeholder="e.g., Jellyfin" style="font-size: 1rem;" />
|
||||
<div id="subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-port-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Port</label>
|
||||
<input type="number" id="service-port-input" placeholder="e.g., 8096" style="font-size: 1rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-ip-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">IP Address</label>
|
||||
<input type="text" id="service-ip-input" placeholder="Auto-detected" style="font-size: 1rem;" />
|
||||
<div class="quick-ip-buttons" style="display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;">
|
||||
<button type="button" class="quick-ip-btn" data-ip="127.0.0.1" title="Localhost" style="font-size: 0.7rem; padding: 2px 6px;">localhost</button>
|
||||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-lan" title="LAN IP" style="font-size: 0.7rem; padding: 2px 6px;">LAN</button>
|
||||
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-tailscale" title="Tailscale IP" style="font-size: 0.7rem; padding: 2px 6px;">Tailscale</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options (collapsed by default) -->
|
||||
<details id="local-advanced-options">
|
||||
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
|
||||
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="service-subdomain-input">Subdomain:</label>
|
||||
<input type="text" id="service-subdomain-input" placeholder="auto-derived from name" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="service-logo-input">Logo URL:</label>
|
||||
<input type="text" id="service-logo-input" placeholder="/assets/name.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: start;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="create-dns-record" checked />
|
||||
Create DNS Record
|
||||
</label>
|
||||
<div>
|
||||
<label for="ssl-type-select">SSL:</label>
|
||||
<select id="ssl-type-select" style="width: 100%;">
|
||||
<option value="caddy-managed">Caddy Managed (Internal)</option>
|
||||
<option value="letsencrypt">Let's Encrypt</option>
|
||||
<option value="existing-ca">Existing CA</option>
|
||||
<option value="custom-ca">Custom CA</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dns-config">
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="dns-ttl-input">DNS TTL:</label>
|
||||
<input type="number" id="dns-ttl-input" value="300" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="caddyfile-path-input">Caddyfile Path:</label>
|
||||
<input type="text" id="caddyfile-path-input" value="C:\\caddy\\Caddyfile" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="existing-ca-config" style="display: none;">
|
||||
<label for="existing-ca-select">Existing CA:</label>
|
||||
<div class="flex-row-gap">
|
||||
<select id="existing-ca-select" style="flex: 1;">
|
||||
<option value="">Loading CAs...</option>
|
||||
</select>
|
||||
<button type="button" id="refresh-cas" style="padding: 4px 8px; font-size: 0.75rem;">\u{1F504}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="custom-ca-config" style="display: none;">
|
||||
<label for="ca-name-input">CA Name:</label>
|
||||
<input type="text" id="ca-name-input" placeholder="e.g., sami-ca" />
|
||||
</div>
|
||||
|
||||
<div id="manual-tailscale-status" style="padding: 6px 10px; background: var(--card-bg); border-radius: 6px; font-size: 0.75rem;">
|
||||
<span style="color: var(--muted);">Checking Tailscale...</span>
|
||||
</div>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="manual-tailscale-only" />
|
||||
Tailscale-Only Access
|
||||
</label>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="reload-caddy" checked />
|
||||
Reload Caddy after adding
|
||||
</label>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label class="field-label-sm">
|
||||
<input type="checkbox" id="enable-auth" />
|
||||
Authentication
|
||||
</label>
|
||||
<label class="field-label-sm">
|
||||
<input type="checkbox" id="enable-cors" />
|
||||
CORS Headers
|
||||
</label>
|
||||
<label for="upstream-path-input">Upstream Path:</label>
|
||||
<input type="text" id="upstream-path-input" value="/" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="health-check-input">Health Check:</label>
|
||||
<input type="text" id="health-check-input" placeholder="/health" />
|
||||
<label for="timeout-input">Timeout (s):</label>
|
||||
<input type="number" id="timeout-input" value="30" />
|
||||
<label for="custom-headers-input">Headers (JSON):</label>
|
||||
<textarea id="custom-headers-input" placeholder='{"X-Custom": "value"}' rows="2" style="font-size: 0.7rem;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- EXTERNAL SERVICE -->
|
||||
<div id="external-service-config" style="display: none;">
|
||||
<div style="display: grid; gap: 12px;">
|
||||
<div>
|
||||
<label for="external-service-name" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
|
||||
<input type="text" id="external-service-name" placeholder="e.g., Radarr (Seedhost)" style="font-size: 1rem;" />
|
||||
<div id="external-subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="external-service-url" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">External URL</label>
|
||||
<input type="url" id="external-service-url" placeholder="https://username.seedhost.eu/radarr" style="font-size: 1rem;" />
|
||||
</div>
|
||||
|
||||
<!-- Options (collapsed by default) -->
|
||||
<details id="external-advanced-options">
|
||||
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
|
||||
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
|
||||
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label for="external-service-subdomain">Subdomain:</label>
|
||||
<input type="text" id="external-service-subdomain" placeholder="auto-derived from name" />
|
||||
<span id="external-domain-preview" style="font-size: 0.7rem; color: var(--accent);"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label for="external-service-logo">Logo URL:</label>
|
||||
<input type="text" id="external-service-logo" placeholder="/assets/name.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="external-service-icon">Icon Emoji:</label>
|
||||
<input type="text" id="external-service-icon" placeholder="\u{1F3AC}" maxlength="2" style="width: 60px;" />
|
||||
</div>
|
||||
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-create-dns" checked />
|
||||
Create DNS Record
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-create-caddy" checked />
|
||||
Create Caddy Reverse Proxy
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="external-proxy-ip">Proxy Server IP:</label>
|
||||
<input type="text" id="external-proxy-ip" placeholder="Auto-detected" />
|
||||
</div>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-preserve-host" checked />
|
||||
Preserve Host Header
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
||||
<input type="checkbox" id="external-follow-redirects" checked />
|
||||
Follow Redirects
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button id="add-service-cancel">Cancel</button>
|
||||
<button id="add-service-create" class="btn-accent">Create Service</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
})();
|
||||
321
status/js/dns-template-selector.js
Normal file
321
status/js/dns-template-selector.js
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* DNS Template Selector
|
||||
* Presents DNS server template options when user chooses to set up DNS
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
class DnsTemplateSelector {
|
||||
constructor(progressTracker) {
|
||||
this.progressTracker = progressTracker;
|
||||
this.modal = null;
|
||||
this.onTemplateSelected = null;
|
||||
console.log('[DnsTemplateSelector] Module loaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available DNS server templates from app templates
|
||||
* @returns {Array} Array of DNS template objects
|
||||
*/
|
||||
getDnsTemplates() {
|
||||
// In a real implementation, this would fetch from app-templates.js
|
||||
// For now, return hardcoded templates matching what we added
|
||||
return [
|
||||
{
|
||||
id: 'technitium',
|
||||
name: 'Technitium DNS Server',
|
||||
description: 'Modern DNS server with web UI for managing private zones',
|
||||
icon: '🌐',
|
||||
difficulty: 'Easy',
|
||||
features: [
|
||||
'Web-based management interface',
|
||||
'Private zone management for .sami domain',
|
||||
'DHCP server integration',
|
||||
'DNS-over-HTTPS and DNS-over-TLS support'
|
||||
],
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'bind9',
|
||||
name: 'BIND9 DNS Server',
|
||||
description: 'Industry-standard DNS server - powerful and flexible',
|
||||
icon: '🔧',
|
||||
difficulty: 'Advanced',
|
||||
features: [
|
||||
'Industry standard DNS server',
|
||||
'Full RFC compliance',
|
||||
'Advanced zone management',
|
||||
'DNSSEC support'
|
||||
],
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
id: 'pihole',
|
||||
name: 'Pi-hole',
|
||||
description: 'Network-wide ad blocker with DNS capabilities',
|
||||
icon: '🛡️',
|
||||
difficulty: 'Intermediate',
|
||||
features: [
|
||||
'Ad blocking at DNS level',
|
||||
'Web interface for management',
|
||||
'DHCP server included',
|
||||
'Query logging and statistics'
|
||||
],
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
id: 'powerdns',
|
||||
name: 'PowerDNS',
|
||||
description: 'High-performance DNS server with SQL backend',
|
||||
icon: '⚡',
|
||||
difficulty: 'Intermediate',
|
||||
features: [
|
||||
'SQL database backend',
|
||||
'RESTful API for automation',
|
||||
'Geographic load balancing',
|
||||
'DNSSEC support'
|
||||
],
|
||||
recommended: false
|
||||
},
|
||||
{
|
||||
id: 'coredns',
|
||||
name: 'CoreDNS',
|
||||
description: 'Cloud-native DNS server - lightweight and flexible',
|
||||
icon: '☁️',
|
||||
difficulty: 'Intermediate',
|
||||
features: [
|
||||
'Plugin-based architecture',
|
||||
'Kubernetes-native',
|
||||
'Lightweight and fast',
|
||||
'Prometheus metrics'
|
||||
],
|
||||
recommended: false
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show DNS template selection modal
|
||||
*/
|
||||
showTemplateSelector() {
|
||||
// Create modal if it doesn't exist
|
||||
if (!this.modal) {
|
||||
this.createModal();
|
||||
}
|
||||
|
||||
// Populate with templates
|
||||
this.populateTemplates();
|
||||
|
||||
// Show modal
|
||||
this.modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the modal HTML structure
|
||||
* @private
|
||||
*/
|
||||
createModal() {
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'dns-template-modal';
|
||||
modal.className = 'dns-template-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="dns-template-modal-content">
|
||||
<div class="dns-template-header">
|
||||
<h2>🌐 Choose a DNS Server</h2>
|
||||
<p>Setting up a DNS server is essential for managing your private .sami domain</p>
|
||||
<button class="dns-template-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="dns-template-grid" id="dns-template-grid">
|
||||
<!-- Templates will be inserted here -->
|
||||
</div>
|
||||
<div class="dns-template-footer">
|
||||
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
this.modal = modal;
|
||||
|
||||
// Add event listeners
|
||||
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
|
||||
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.style.display === 'flex') {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate modal with DNS templates
|
||||
* @private
|
||||
*/
|
||||
populateTemplates() {
|
||||
const grid = document.getElementById('dns-template-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const templates = this.getDnsTemplates();
|
||||
grid.innerHTML = '';
|
||||
|
||||
templates.forEach(template => {
|
||||
const card = this.createTemplateCard(template);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a template card element
|
||||
* @private
|
||||
*/
|
||||
createTemplateCard(template) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'dns-template-card';
|
||||
if (template.recommended) {
|
||||
card.classList.add('recommended');
|
||||
}
|
||||
|
||||
const difficultyClass = template.difficulty.toLowerCase();
|
||||
|
||||
card.innerHTML = `
|
||||
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
|
||||
<div class="dns-template-icon">${template.icon}</div>
|
||||
<h3>${template.name}</h3>
|
||||
<p class="dns-template-description">${template.description}</p>
|
||||
<div class="dns-template-difficulty difficulty-${difficultyClass}">
|
||||
${template.difficulty}
|
||||
</div>
|
||||
<ul class="dns-template-features">
|
||||
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
<button class="dns-template-select-btn" data-template-id="${template.id}">
|
||||
Select ${template.name}
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click handler to select button
|
||||
const selectBtn = card.querySelector('.dns-template-select-btn');
|
||||
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle template selection
|
||||
* @private
|
||||
*/
|
||||
handleTemplateSelection(template) {
|
||||
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
|
||||
|
||||
// Close modal
|
||||
this.close();
|
||||
|
||||
// Trigger callback if set
|
||||
if (this.onTemplateSelected) {
|
||||
this.onTemplateSelected(template);
|
||||
} else {
|
||||
// Default behavior: open app selector with DNS filter
|
||||
this.openAppSelector(template.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle "Set up later" button
|
||||
* @private
|
||||
*/
|
||||
handleSetupLater() {
|
||||
console.log('[DnsTemplateSelector] DNS setup deferred');
|
||||
|
||||
// Mark as deferred in progress tracker
|
||||
if (this.progressTracker) {
|
||||
this.progressTracker.markDnsSetupDeferred();
|
||||
}
|
||||
|
||||
// Close modal
|
||||
this.close();
|
||||
|
||||
// Show notification
|
||||
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open app selector with specific template
|
||||
* @private
|
||||
*/
|
||||
openAppSelector(templateId) {
|
||||
// Try to open the app selector modal if it exists
|
||||
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
|
||||
if (appSelectorBtn) {
|
||||
appSelectorBtn.click();
|
||||
|
||||
// Wait a bit then filter to the selected template
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('#app-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = templateId;
|
||||
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
// Fallback: show instructions
|
||||
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification message
|
||||
* @private
|
||||
*/
|
||||
showNotification(message) {
|
||||
// Simple notification - could be enhanced
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'dns-template-notification';
|
||||
notification.textContent = message;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: var(--card-base);
|
||||
color: var(--fg);
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 10001;
|
||||
max-width: 300px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => notification.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
close() {
|
||||
if (this.modal) {
|
||||
this.modal.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.DnsTemplateSelector = DnsTemplateSelector;
|
||||
console.log('[DnsTemplateSelector] Module loaded');
|
||||
|
||||
})(window);
|
||||
2
status/js/driver.min.js
vendored
Normal file
2
status/js/driver.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
259
status/js/error-handler.js
Normal file
259
status/js/error-handler.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Error Handler
|
||||
* Handles errors gracefully without breaking the onboarding tour
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
class ErrorHandler {
|
||||
constructor() {
|
||||
this.errors = [];
|
||||
this.maxErrors = 50; // Keep last 50 errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error without breaking the tour
|
||||
* @param {string} context - Context where error occurred
|
||||
* @param {Error|string} error - The error object or message
|
||||
* @param {Object} metadata - Additional metadata
|
||||
*/
|
||||
logError(context, error, metadata = {}) {
|
||||
const errorEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
context,
|
||||
message: error instanceof Error ? error.message : error,
|
||||
stack: error instanceof Error ? error.stack : null,
|
||||
metadata
|
||||
};
|
||||
|
||||
// Add to errors array
|
||||
this.errors.push(errorEntry);
|
||||
|
||||
// Keep only last maxErrors
|
||||
if (this.errors.length > this.maxErrors) {
|
||||
this.errors.shift();
|
||||
}
|
||||
|
||||
// Log to console
|
||||
console.error(`[Onboarding Error] ${context}:`, error, metadata);
|
||||
|
||||
// Optionally send to error tracking service
|
||||
// this.sendToErrorTracking(errorEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover from an error and continue tour
|
||||
* @param {Error} error - The error object
|
||||
* @param {number} currentStep - Current step index
|
||||
* @returns {Object} Recovery action
|
||||
*/
|
||||
recoverFromError(error, currentStep) {
|
||||
const errorType = this.classifyError(error);
|
||||
|
||||
switch (errorType) {
|
||||
case 'ELEMENT_NOT_FOUND':
|
||||
this.logError('Element Not Found', error, { currentStep });
|
||||
return {
|
||||
action: 'SKIP_STEP',
|
||||
nextStep: currentStep + 1,
|
||||
message: 'Target element not found, skipping to next step'
|
||||
};
|
||||
|
||||
case 'STORAGE_UNAVAILABLE':
|
||||
this.logError('Storage Unavailable', error);
|
||||
return {
|
||||
action: 'USE_MEMORY_STORAGE',
|
||||
message: 'Local storage unavailable, using in-memory storage'
|
||||
};
|
||||
|
||||
case 'DRIVER_NOT_LOADED':
|
||||
this.logError('Driver.js Not Loaded', error);
|
||||
return {
|
||||
action: 'ABORT_TOUR',
|
||||
message: 'Driver.js library not loaded, cannot start tour'
|
||||
};
|
||||
|
||||
case 'INVALID_TOOLTIP':
|
||||
this.logError('Invalid Tooltip Configuration', error, { currentStep });
|
||||
return {
|
||||
action: 'SKIP_STEP',
|
||||
nextStep: currentStep + 1,
|
||||
message: 'Invalid tooltip configuration, skipping'
|
||||
};
|
||||
|
||||
case 'THEME_DETECTION_FAILED':
|
||||
this.logError('Theme Detection Failed', error);
|
||||
return {
|
||||
action: 'USE_DEFAULT_THEME',
|
||||
message: 'Using default dark theme'
|
||||
};
|
||||
|
||||
default:
|
||||
this.logError('Unknown Error', error, { currentStep });
|
||||
return {
|
||||
action: 'ABORT_TOUR',
|
||||
message: 'Unexpected error occurred, aborting tour'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify error type
|
||||
* @private
|
||||
* @param {Error} error - The error object
|
||||
* @returns {string} Error type
|
||||
*/
|
||||
classifyError(error) {
|
||||
const message = error.message || error.toString();
|
||||
|
||||
if (message.includes('element') && message.includes('not found')) {
|
||||
return 'ELEMENT_NOT_FOUND';
|
||||
}
|
||||
if (message.includes('storage') || message.includes('quota')) {
|
||||
return 'STORAGE_UNAVAILABLE';
|
||||
}
|
||||
if (message.includes('driver') || message.includes('undefined')) {
|
||||
return 'DRIVER_NOT_LOADED';
|
||||
}
|
||||
if (message.includes('invalid') || message.includes('validation')) {
|
||||
return 'INVALID_TOOLTIP';
|
||||
}
|
||||
if (message.includes('theme')) {
|
||||
return 'THEME_DETECTION_FAILED';
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logged errors
|
||||
* @returns {Array} Array of error entries
|
||||
*/
|
||||
getErrors() {
|
||||
return [...this.errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logged errors
|
||||
*/
|
||||
clearErrors() {
|
||||
this.errors = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error statistics
|
||||
* @returns {Object} Error statistics
|
||||
*/
|
||||
getStatistics() {
|
||||
const stats = {
|
||||
total: this.errors.length,
|
||||
byContext: {},
|
||||
byType: {},
|
||||
recent: this.errors.slice(-10)
|
||||
};
|
||||
|
||||
this.errors.forEach(error => {
|
||||
// Count by context
|
||||
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
|
||||
|
||||
// Count by type
|
||||
const type = this.classifyError({ message: error.message });
|
||||
stats.byType[type] = (stats.byType[type] || 0) + 1;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle graceful degradation when Driver.js fails to load
|
||||
* @returns {boolean} Whether fallback was successful
|
||||
*/
|
||||
handleDriverLoadFailure() {
|
||||
this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
|
||||
|
||||
// Show fallback message
|
||||
const fallbackMessage = document.createElement('div');
|
||||
fallbackMessage.id = 'onboarding-fallback';
|
||||
fallbackMessage.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--card-base, #2a2a2a);
|
||||
color: var(--fg, #ffffff);
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 9999;
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
fallbackMessage.innerHTML = `
|
||||
<strong>Welcome to DashCaddy!</strong><br>
|
||||
<p style="margin: 10px 0 0 0; font-size: 12px;">
|
||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||
Check the documentation for help getting started.
|
||||
</p>
|
||||
`;
|
||||
|
||||
document.body.appendChild(fallbackMessage);
|
||||
|
||||
// Auto-remove after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (fallbackMessage.parentNode) {
|
||||
fallbackMessage.parentNode.removeChild(fallbackMessage);
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle storage unavailable scenario
|
||||
* @returns {Object} In-memory storage fallback
|
||||
*/
|
||||
handleStorageUnavailable() {
|
||||
this.logError('Storage Unavailable', 'Local storage is not available');
|
||||
|
||||
// Create in-memory storage
|
||||
const memoryStorage = {
|
||||
data: {},
|
||||
getItem(key) {
|
||||
return this.data[key] || null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
this.data[key] = value;
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.data[key];
|
||||
},
|
||||
clear() {
|
||||
this.data = {};
|
||||
}
|
||||
};
|
||||
|
||||
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
|
||||
return memoryStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error to tracking service (placeholder)
|
||||
* @private
|
||||
* @param {Object} errorEntry - Error entry to send
|
||||
*/
|
||||
sendToErrorTracking(errorEntry) {
|
||||
// Placeholder for error tracking integration
|
||||
// Could integrate with Sentry, LogRocket, etc.
|
||||
// Example:
|
||||
// if (window.Sentry) {
|
||||
// Sentry.captureException(new Error(errorEntry.message), {
|
||||
// extra: errorEntry.metadata
|
||||
// });
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
window.ErrorHandler = ErrorHandler;
|
||||
console.log('[ErrorHandler] Module loaded');
|
||||
|
||||
})(window);
|
||||
72
status/js/error-logs.js
Normal file
72
status/js/error-logs.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ========== ERROR LOG VIEWER ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('error-log-modal', '<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>📋 Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">🔄 Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">🗑️ Clear</button><button id="error-log-close" class="close-btn">✕</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');
|
||||
|
||||
const modal = document.getElementById('error-log-modal');
|
||||
const content = document.getElementById('error-log-content');
|
||||
const viewBtn = document.getElementById('view-error-logs');
|
||||
const refreshBtn = document.getElementById('error-log-refresh');
|
||||
const clearBtn = document.getElementById('error-log-clear');
|
||||
const closeBtn = document.getElementById('error-log-close');
|
||||
|
||||
async function loadErrorLogs() {
|
||||
content.innerHTML = '<div class="logs-loading">Loading error logs...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/error-logs');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.logs) {
|
||||
if (data.logs.length === 0) {
|
||||
content.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--muted);">✅ No errors logged! Everything is working smoothly.</div>';
|
||||
} else {
|
||||
content.innerHTML = data.logs.map(log => {
|
||||
const date = new Date(log.timestamp).toLocaleString();
|
||||
return `
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${date}</span>
|
||||
<span class="log-level">ERROR</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(log.context)}</strong>: ${escapeHtml(log.error)}
|
||||
${log.details ? `<br><small style="opacity: 0.7;">${escapeHtml(log.details)}</small>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
content.innerHTML = '<div style="padding: 20px; color: var(--bad-fg);">❌ Failed to load error logs</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
content.innerHTML = `<div style="padding: 20px; color: var(--bad-fg);">❌ Error loading logs: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearErrorLogs() {
|
||||
if (!confirm('Clear all error logs?')) return;
|
||||
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/error-logs', { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showNotification('✅ Error logs cleared', 'success', 3000);
|
||||
loadErrorLogs();
|
||||
} else {
|
||||
showNotification('❌ Failed to clear logs', 'error', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(`❌ Error: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
}
|
||||
|
||||
viewBtn?.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
loadErrorLogs();
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener('click', loadErrorLogs);
|
||||
clearBtn?.addEventListener('click', clearErrorLogs);
|
||||
wireModal(modal, closeBtn);
|
||||
})();
|
||||
348
status/js/globals.js
Normal file
348
status/js/globals.js
Normal file
@@ -0,0 +1,348 @@
|
||||
// ===== DASHBOARD CONSTANTS =====
|
||||
const DC = {
|
||||
NAME: 'DashCaddy',
|
||||
POLL: {
|
||||
DASHBOARD: 10000, // 10s — main refreshAll interval
|
||||
LOGS: 3000, // 3s — log viewer updates
|
||||
STATS: 5000, // 5s — resource monitor refresh
|
||||
WEATHER: 600000, // 10m — weather widget refresh
|
||||
HEALTH: 1000, // 1s — card health badge refresh
|
||||
DEPLOY_SSL: 5000, // 5s — SSL cert check during deploy
|
||||
},
|
||||
DELAYS: {
|
||||
BTN_RESET: 2000, // Button text reset after action
|
||||
RELOAD: 5000, // Page reload after restart
|
||||
MODAL_CLOSE: 500, // Modal close animation
|
||||
PORT_CHECK: 500, // Debounce for port availability check
|
||||
DEPLOY_INIT: 3000, // Initial deploy cert check delay
|
||||
},
|
||||
DEFAULTS: {
|
||||
DNS_PORT: '5380',
|
||||
SERVICE_PORT: '8080',
|
||||
TTL: 300,
|
||||
CADDYFILE: 'C:\\caddy\\Caddyfile',
|
||||
},
|
||||
};
|
||||
|
||||
// ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) =====
|
||||
const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null');
|
||||
const SITE = {
|
||||
tld: (_cachedCfg && _cachedCfg.tld) || '.home',
|
||||
dnsIp: (_cachedCfg && _cachedCfg.dnsIp) || '',
|
||||
dnsPort: (_cachedCfg && _cachedCfg.dnsPort) || DC.DEFAULTS.DNS_PORT,
|
||||
dnsServers: (_cachedCfg && _cachedCfg.dnsServers) || {},
|
||||
configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab',
|
||||
domain: (_cachedCfg && _cachedCfg.domain) || '',
|
||||
defaults: (_cachedCfg && _cachedCfg.defaults) || {}
|
||||
};
|
||||
(async function loadSiteConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/config');
|
||||
if (r.ok) {
|
||||
const c = await r.json();
|
||||
if (c.tld) SITE.tld = c.tld.startsWith('.') ? c.tld : '.' + c.tld;
|
||||
if (c.dns) {
|
||||
SITE.dnsIp = c.dns.ip || '';
|
||||
SITE.dnsPort = c.dns.port || DC.DEFAULTS.DNS_PORT;
|
||||
}
|
||||
if (c.dnsServers) {
|
||||
Object.assign(SITE.dnsServers, c.dnsServers);
|
||||
}
|
||||
if (c.configurationType) SITE.configurationType = c.configurationType;
|
||||
if (c.domain) SITE.domain = c.domain;
|
||||
if (c.defaults) SITE.defaults = c.defaults;
|
||||
// Cache config so next page load uses correct TLD even if API is slow
|
||||
localStorage.setItem('dashcaddy_site_config', JSON.stringify({
|
||||
tld: SITE.tld, dnsIp: SITE.dnsIp, dnsPort: SITE.dnsPort, dnsServers: SITE.dnsServers,
|
||||
configurationType: SITE.configurationType, domain: SITE.domain, defaults: SITE.defaults
|
||||
}));
|
||||
// Render DNS cards dynamically based on configured servers
|
||||
renderDnsCards();
|
||||
}
|
||||
} catch (_) {}
|
||||
// Update static HTML elements with configured TLD
|
||||
document.querySelectorAll('[data-tld]').forEach(el => el.textContent = SITE.tld);
|
||||
const tldSuffix = document.getElementById('edit-tld-suffix');
|
||||
if (tldSuffix) tldSuffix.textContent = SITE.tld;
|
||||
const proxyIpInput = document.getElementById('external-proxy-ip');
|
||||
if (proxyIpInput && SITE.dnsIp) { proxyIpInput.value = SITE.dnsIp; proxyIpInput.placeholder = SITE.dnsIp; }
|
||||
})();
|
||||
function buildDomain(sub) { return sub + SITE.tld; }
|
||||
function getDnsServerAddr(dnsId) {
|
||||
const s = SITE.dnsServers[dnsId];
|
||||
return s ? `${s.ip}:${s.port}` : buildDomain(dnsId);
|
||||
}
|
||||
|
||||
// ===== DYNAMIC DNS CARD RENDERER =====
|
||||
function renderDnsCards() {
|
||||
const topRow = document.querySelector('.top');
|
||||
if (!topRow) return;
|
||||
const dnsIds = Object.keys(SITE.dnsServers);
|
||||
if (!dnsIds.length) return; // No DNS servers configured — show nothing
|
||||
|
||||
const svgIcon = '<svg viewBox="0 0 24 24" class="service-icon">'
|
||||
+ '<rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/>'
|
||||
+ '<rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/>'
|
||||
+ '<rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/>'
|
||||
+ '<rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/>'
|
||||
+ '<rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/>'
|
||||
+ '<rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/>'
|
||||
+ '<circle cx="17" cy="11" r="2" fill="#e74c3c"/>'
|
||||
+ '<path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>';
|
||||
|
||||
const firstChild = topRow.firstElementChild;
|
||||
dnsIds.forEach(id => {
|
||||
const label = (SITE.dnsServers[id].name || id).toUpperCase();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.setAttribute('data-app', id);
|
||||
card.setAttribute('data-status', 'off');
|
||||
card.innerHTML =
|
||||
`<span id="${id}-dot" class="dot bad at-bl"></span>`
|
||||
+ `<div class="row"><div class="logo-wrap">${svgIcon}</div>`
|
||||
+ `<span class="name">${label}</span><span class="spacer"></span>`
|
||||
+ `<span id="${id}-pill" class="badge off">OFF</span></div>`
|
||||
+ `<div class="response-row"><span id="${id}-time" class="response-time">--</span></div>`
|
||||
+ `<div class="btn-row">`
|
||||
+ `<button id="${id}-restart" class="restart-btn">Restart</button>`
|
||||
+ `<button id="${id}-update" class="update-btn" title="Update DNS server">⬆️</button>`
|
||||
+ `<button id="${id}-open">Open</button>`
|
||||
+ `<button id="${id}-logs" class="logs-btn">Logs</button>`
|
||||
+ `<button id="${id}-settings" class="settings-btn">⚙️</button>`
|
||||
+ `</div>`;
|
||||
topRow.insertBefore(card, firstChild);
|
||||
});
|
||||
}
|
||||
window.renderDnsCards = renderDnsCards;
|
||||
|
||||
// ===== CSRF PROTECTION =====
|
||||
let csrfToken = null;
|
||||
|
||||
/**
|
||||
* Get CSRF token from server (cached)
|
||||
* @returns {Promise<string>} CSRF token
|
||||
*/
|
||||
async function getCSRFToken() {
|
||||
if (csrfToken) return csrfToken;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/csrf-token');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch CSRF token');
|
||||
}
|
||||
const data = await response.json();
|
||||
csrfToken = data.token;
|
||||
return csrfToken;
|
||||
} catch (error) {
|
||||
console.error('Failed to get CSRF token:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure fetch wrapper that automatically adds CSRF token to state-changing requests
|
||||
* @param {string} url - URL to fetch
|
||||
* @param {Object} options - Fetch options
|
||||
* @returns {Promise<Response>} Fetch response
|
||||
*/
|
||||
async function secureFetch(url, options = {}) {
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
|
||||
// Add CSRF token for state-changing methods
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
try {
|
||||
const token = await getCSRFToken();
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'X-CSRF-Token': token
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to add CSRF token to request:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Default 15s timeout if no signal provided (prevents hanging requests)
|
||||
if (!options.signal) {
|
||||
options = { ...options, signal: AbortSignal.timeout(15000) };
|
||||
}
|
||||
|
||||
return fetch(url, options);
|
||||
}
|
||||
|
||||
// ===== API CALL HELPERS =====
|
||||
|
||||
/** POST JSON and return parsed response. Throws on HTTP or API error. */
|
||||
async function postJSON(url, data) {
|
||||
const resp = await secureFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || result.success === false) {
|
||||
throw new Error(result.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** GET JSON and return parsed response. Throws on HTTP error with server message. */
|
||||
async function getJSON(url) {
|
||||
const resp = await secureFetch(url);
|
||||
if (!resp.ok) {
|
||||
let msg = `Request failed (${resp.status})`;
|
||||
try { const body = await resp.json(); msg = body.error || msg; } catch (_) {}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** DELETE request. Returns parsed JSON response. */
|
||||
async function deleteAPI(url) {
|
||||
const resp = await secureFetch(url, { method: 'DELETE' });
|
||||
const result = await resp.json();
|
||||
if (!resp.ok || result.success === false) {
|
||||
throw new Error(result.error || `Delete failed (${resp.status})`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an async operation with button loading state.
|
||||
* Disables the button, shows loading text, restores on complete.
|
||||
* @param {HTMLElement} btn - Button element
|
||||
* @param {string} loadingText - Text to show while loading (e.g. 'Saving...')
|
||||
* @param {function} asyncFn - Async function to execute
|
||||
* @param {Object} opts - Options: { successText, resetDelay }
|
||||
*/
|
||||
async function withButton(btn, loadingText, asyncFn, opts = {}) {
|
||||
const original = btn.innerHTML;
|
||||
const { successText = '✅', resetDelay = DC.DELAYS.BTN_RESET } = opts;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = loadingText;
|
||||
try {
|
||||
const result = await asyncFn();
|
||||
btn.innerHTML = successText;
|
||||
setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, resetDelay);
|
||||
return result;
|
||||
} catch (e) {
|
||||
btn.innerHTML = original;
|
||||
btn.disabled = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Show/hide a modal by ID */
|
||||
function openModal(id) {
|
||||
document.getElementById(id)?.classList.add('show');
|
||||
}
|
||||
function closeModal(id) {
|
||||
document.getElementById(id)?.classList.remove('show');
|
||||
}
|
||||
|
||||
/** Wire backdrop-click close + optional close buttons for a modal element */
|
||||
function wireModal(modal, ...closeBtns) {
|
||||
if (!modal) return;
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('show'); });
|
||||
closeBtns.forEach(btn => btn?.addEventListener('click', () => modal.classList.remove('show')));
|
||||
}
|
||||
|
||||
/** Toast-style notification (replaces all alert() usage) */
|
||||
function showNotification(text, type = 'info', duration = 3000) {
|
||||
const existingNotif = document.querySelector('.deploy-notification');
|
||||
if (existingNotif) existingNotif.remove();
|
||||
|
||||
const colors = {
|
||||
info: { bg: '#2196F3', fg: '#fff' },
|
||||
success: { bg: 'var(--ok-bg)', fg: 'var(--ok-fg)' },
|
||||
error: { bg: '#f44336', fg: '#fff' },
|
||||
warning: { bg: '#ff9800', fg: '#fff' }
|
||||
};
|
||||
|
||||
const c = colors[type] || colors.info;
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'deploy-notification';
|
||||
msg.textContent = text;
|
||||
msg.style.cssText = `
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
background: ${c.bg}; color: ${c.fg};
|
||||
padding: 16px 24px; border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
z-index: 10000; animation: slideIn 0.3s ease-out;
|
||||
max-width: 400px; white-space: pre-line; font-size: 14px;
|
||||
`;
|
||||
document.body.appendChild(msg);
|
||||
if (duration > 0) setTimeout(() => msg.remove(), duration);
|
||||
}
|
||||
|
||||
/** Relative time display (e.g. "5m ago", "2h ago") */
|
||||
function timeAgo(ts) {
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||||
return Math.floor(diff / 86400000) + 'd ago';
|
||||
}
|
||||
|
||||
// ===== SAFE STORAGE WRAPPERS =====
|
||||
// Prevents crashes in Safari private browsing, quota exceeded, or restricted environments
|
||||
function safeGet(key, fallback = null) {
|
||||
try { const v = localStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; }
|
||||
}
|
||||
function safeSet(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch (_) { /* quota exceeded or private mode */ }
|
||||
}
|
||||
function safeRemove(key) {
|
||||
try { localStorage.removeItem(key); } catch (_) {}
|
||||
}
|
||||
function safeSessionGet(key, fallback = null) {
|
||||
try { const v = sessionStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; }
|
||||
}
|
||||
function safeSessionSet(key, value) {
|
||||
try { sessionStorage.setItem(key, value); } catch (_) {}
|
||||
}
|
||||
/** Parse JSON from localStorage with fallback — avoids try/catch at every call site */
|
||||
function safeGetJSON(key, fallback = null) {
|
||||
try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch (_) { return fallback; }
|
||||
}
|
||||
|
||||
/** Escape HTML entities for safe innerHTML insertion (handles both content and attributes) */
|
||||
function escapeHtml(text) {
|
||||
return String(text ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Inject modal HTML into DOM (idempotent — skips if element already exists) */
|
||||
function injectModal(id, html) {
|
||||
if (document.getElementById(id)) return;
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
|
||||
// ===== EVENT BUS =====
|
||||
// Lightweight pub/sub for cross-module communication without window globals.
|
||||
// Usage: DC_BUS.on('services:loaded', handler); DC_BUS.emit('services:loaded', data);
|
||||
const DC_BUS = {
|
||||
_handlers: {},
|
||||
on(event, fn) { (this._handlers[event] ||= []).push(fn); },
|
||||
off(event, fn) { this._handlers[event] = this._handlers[event]?.filter(h => h !== fn); },
|
||||
emit(event, data) { this._handlers[event]?.forEach(fn => fn(data)); }
|
||||
};
|
||||
|
||||
// ===== CENTRALIZED APP STATE =====
|
||||
// Single source of truth for the services array. Modules should use AppState
|
||||
// instead of mutating window.APPS directly. Emits 'apps:changed' on updates.
|
||||
const AppState = {
|
||||
_apps: [],
|
||||
getApps() { return this._apps; },
|
||||
setApps(apps) { this._apps = apps; window.APPS = apps; DC_BUS.emit('apps:changed', apps); },
|
||||
findApp(id) { return this._apps.find(a => a.id === id); },
|
||||
addApp(app) { this._apps.push(app); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); },
|
||||
removeApp(id) {
|
||||
const idx = this._apps.findIndex(a => a.id === id);
|
||||
if (idx > -1) { this._apps.splice(idx, 1); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); }
|
||||
return idx > -1;
|
||||
},
|
||||
updateApp(id, changes) {
|
||||
const app = this._apps.find(a => a.id === id);
|
||||
if (app) { Object.assign(app, changes); DC_BUS.emit('apps:changed', this._apps); }
|
||||
return app;
|
||||
}
|
||||
};
|
||||
371
status/js/health-check.js
Normal file
371
status/js/health-check.js
Normal file
@@ -0,0 +1,371 @@
|
||||
// ========== HEALTH CHECK DASHBOARD ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('health-modal', `<div id="health-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<h3>🏥 Health Check Dashboard</h3>
|
||||
<p class="modal-subtitle">
|
||||
Service uptime tracking, SLA monitoring, and incident management.
|
||||
</p>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" data-panel="health-status">Status</button>
|
||||
<button class="panel-tab" data-panel="health-incidents">Incidents</button>
|
||||
<button class="panel-tab" data-panel="health-config">Configure</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Status -->
|
||||
<div id="health-status" class="panel-section active">
|
||||
<div id="health-status-container" class="scroll-container">
|
||||
<div class="panel-empty"><span class="brand-spinner"></span> Loading health status...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Incidents -->
|
||||
<div id="health-incidents" class="panel-section">
|
||||
<div id="health-incidents-container" class="scroll-container">
|
||||
<div class="panel-empty"><span class="empty-icon">🚨</span> Loading incidents...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Configure -->
|
||||
<div id="health-config" class="panel-section">
|
||||
<div id="health-config-container" class="scroll-container">
|
||||
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Health Check Form -->
|
||||
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
|
||||
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
|
||||
<div>
|
||||
<label class="text-muted-sm">Service ID</label>
|
||||
<input type="text" id="health-form-id" placeholder="e.g. plex" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Display Name</label>
|
||||
<input type="text" id="health-form-name" placeholder="e.g. Plex Media Server" class="form-input" />
|
||||
</div>
|
||||
<div style="grid-column: span 2;">
|
||||
<label class="text-muted-sm">URL</label>
|
||||
<input type="text" id="health-form-url" placeholder="https://plex.home" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Timeout (ms)</label>
|
||||
<input type="number" id="health-form-timeout" value="10000" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Expected Status Codes</label>
|
||||
<input type="text" id="health-form-codes" value="200" placeholder="200, 301, 302" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">SLA Target (%)</label>
|
||||
<input type="number" id="health-form-sla" value="99.9" step="0.1" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted-sm">Slow Response Threshold (ms)</label>
|
||||
<input type="number" id="health-form-slow" value="5000" class="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button id="health-form-cancel">Cancel</button>
|
||||
<button id="health-form-save" class="btn-accent-solid">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 12px;">
|
||||
<button id="health-add-btn" class="btn-accent-solid">+ Add Health Check</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-bottom-bar">
|
||||
<button id="health-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
||||
<span id="health-last-update" class="text-auto-right"></span>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="health-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('health-modal');
|
||||
const openBtn = document.getElementById('health-check-btn');
|
||||
const cancelBtn = document.getElementById('health-cancel');
|
||||
const refreshBtn = document.getElementById('health-refresh-btn');
|
||||
const statusContainer = document.getElementById('health-status-container');
|
||||
const incidentsContainer = document.getElementById('health-incidents-container');
|
||||
const configContainer = document.getElementById('health-config-container');
|
||||
const lastUpdateSpan = document.getElementById('health-last-update');
|
||||
const addBtn = document.getElementById('health-add-btn');
|
||||
const formEl = document.getElementById('health-config-form');
|
||||
const formTitle = document.getElementById('health-form-title');
|
||||
const formCancel = document.getElementById('health-form-cancel');
|
||||
const formSave = document.getElementById('health-form-save');
|
||||
|
||||
let editingId = null;
|
||||
|
||||
function uptimeColor(pct) {
|
||||
if (pct >= 99.9) return 'var(--ok-fg)';
|
||||
if (pct >= 95) return '#f39c12';
|
||||
return 'var(--bad-fg)';
|
||||
}
|
||||
|
||||
function severityBadge(sev) {
|
||||
const colors = { critical: 'var(--bad-fg)', high: '#ff6b6b', medium: '#f39c12', low: 'var(--muted)' };
|
||||
return `<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${colors[sev] || 'var(--muted)'}20; color: ${colors[sev] || 'var(--muted)'};">${sev}</span>`;
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/health-checks/status');
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.status || Object.keys(data.status).length === 0) {
|
||||
statusContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🏥</span>No health checks configured. Go to the Configure tab to add services.</div>';
|
||||
return;
|
||||
}
|
||||
const services = Object.values(data.status);
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">';
|
||||
html += '<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>';
|
||||
html += '<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>';
|
||||
html += '<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';
|
||||
for (const s of services) {
|
||||
const isUp = s.status === 'up';
|
||||
const dotColor = isUp ? 'var(--dot-ok)' : 'var(--dot-bad)';
|
||||
const u24 = s.uptime?.['24h'] ?? '-';
|
||||
const u7d = s.uptime?.['7d'] ?? '-';
|
||||
const avgRt = s.avgResponseTime != null ? Math.round(s.avgResponseTime) + 'ms' : '-';
|
||||
const lastCheck = s.timestamp ? timeAgo(s.timestamp) : '-';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${s.serviceId}">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${s.name || s.serviceId}</td>`;
|
||||
html += `<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${dotColor}; margin-right: 6px;"></span>${isUp ? 'Up' : 'Down'}</td>`;
|
||||
html += `<td style="padding: 8px; color: ${typeof u24 === 'number' ? uptimeColor(u24) : 'var(--muted)'};">${typeof u24 === 'number' ? u24.toFixed(1) + '%' : u24}</td>`;
|
||||
html += `<td style="padding: 8px; color: ${typeof u7d === 'number' ? uptimeColor(u7d) : 'var(--muted)'};">${typeof u7d === 'number' ? u7d.toFixed(1) + '%' : u7d}</td>`;
|
||||
html += `<td style="padding: 8px;">${avgRt}</td>`;
|
||||
html += `<td style="padding: 8px; color: var(--muted);">${lastCheck}</td>`;
|
||||
html += '</tr>';
|
||||
html += `<tr id="health-detail-${s.serviceId}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`;
|
||||
}
|
||||
html += '</table>';
|
||||
statusContainer.innerHTML = html;
|
||||
lastUpdateSpan.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
|
||||
// Row click to expand details
|
||||
statusContainer.querySelectorAll('tr[data-health-id]').forEach(row => {
|
||||
row.addEventListener('click', async () => {
|
||||
const id = row.dataset.healthId;
|
||||
const detailRow = document.getElementById('health-detail-' + id);
|
||||
if (!detailRow) return;
|
||||
if (detailRow.style.display !== 'none') {
|
||||
detailRow.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
detailRow.style.display = '';
|
||||
try {
|
||||
const r = await fetch(`/api/v1/health-checks/${id}/stats?hours=24`);
|
||||
const d = await r.json();
|
||||
if (d.success && d.stats) {
|
||||
const st = d.stats;
|
||||
const rt = st.responseTime || {};
|
||||
detailRow.querySelector('td').innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${st.totalChecks || 0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${uptimeColor(st.uptime || 0)};">${(st.uptime || 0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(rt.avg || 0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(rt.p95 || 0)}ms / ${Math.round(rt.p99 || 0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(rt.min || 0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(rt.max || 0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${st.upChecks || 0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${st.downChecks || 0}</strong></div>
|
||||
</div>`;
|
||||
} else {
|
||||
detailRow.querySelector('td').innerHTML = '<div class="panel-empty">No detailed stats available for this period.</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
detailRow.querySelector('td').innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
statusContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIncidents() {
|
||||
try {
|
||||
const [openRes, histRes] = await Promise.all([
|
||||
fetch('/api/v1/health-checks/incidents'),
|
||||
fetch('/api/v1/health-checks/incidents/history?limit=50')
|
||||
]);
|
||||
const openData = await openRes.json();
|
||||
const histData = await histRes.json();
|
||||
let html = '';
|
||||
|
||||
// Open incidents
|
||||
const open = (openData.success && openData.incidents) ? openData.incidents : [];
|
||||
if (open.length > 0) {
|
||||
html += '<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents (' + open.length + ')</h4>';
|
||||
for (const inc of open) {
|
||||
html += `<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 500;">${inc.serviceId}</span>
|
||||
<span>${severityBadge(inc.severity)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${inc.message}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(inc.createdAt)} · ${inc.occurrences || 1} occurrence(s)</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational — no open incidents</div>';
|
||||
}
|
||||
|
||||
// Incident history
|
||||
const history = (histData.success && histData.history) ? histData.history : [];
|
||||
if (history.length > 0) {
|
||||
html += '<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>';
|
||||
html += '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';
|
||||
for (const inc of history) {
|
||||
const resolved = inc.status === 'resolved';
|
||||
const dur = resolved && inc.duration ? (inc.duration < 60000 ? Math.round(inc.duration / 1000) + 's' : Math.round(inc.duration / 60000) + 'm') : '-';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 6px;">${inc.serviceId}</td>`;
|
||||
html += `<td style="padding: 6px;">${inc.type}</td>`;
|
||||
html += `<td style="padding: 6px;">${severityBadge(inc.severity)}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${resolved ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${inc.status}</span></td>`;
|
||||
html += `<td style="padding: 6px;">${dur}</td>`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(inc.createdAt)}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
incidentsContainer.innerHTML = html || '<div class="panel-empty"><span class="empty-icon">🚨</span>No incidents recorded yet.</div>';
|
||||
} catch (e) {
|
||||
incidentsContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/health-checks/status');
|
||||
const data = await res.json();
|
||||
const services = data.success && data.status ? Object.values(data.status) : [];
|
||||
if (services.length === 0) {
|
||||
configContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">⚙️</span>No health checks configured yet. Click "Add Health Check" below.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
||||
for (const s of services) {
|
||||
const isUp = s.status === 'up';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${s.name || s.serviceId}</td>`;
|
||||
html += `<td style="padding: 8px; color: ${isUp ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${isUp ? 'Up' : 'Down'}</td>`;
|
||||
html += `<td style="padding: 8px;">${s.sla?.target ? s.sla.target + '%' : '-'}</td>`;
|
||||
html += `<td style="padding: 8px; text-align: right;">`;
|
||||
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${s.serviceId}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`;
|
||||
html += `<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${s.serviceId}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`;
|
||||
html += '</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
configContainer.innerHTML = html;
|
||||
} catch (e) {
|
||||
configContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showForm(id, name, url, timeout, codes, sla, slow) {
|
||||
editingId = id || null;
|
||||
formTitle.textContent = id ? 'Edit Health Check' : 'Add Health Check';
|
||||
document.getElementById('health-form-id').value = id || '';
|
||||
document.getElementById('health-form-id').disabled = !!id;
|
||||
document.getElementById('health-form-name').value = name || '';
|
||||
document.getElementById('health-form-url').value = url || '';
|
||||
document.getElementById('health-form-timeout').value = timeout || 10000;
|
||||
document.getElementById('health-form-codes').value = codes || '200';
|
||||
document.getElementById('health-form-sla').value = sla || 99.9;
|
||||
document.getElementById('health-form-slow').value = slow || 5000;
|
||||
formEl.style.display = '';
|
||||
addBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function hideForm() {
|
||||
formEl.style.display = 'none';
|
||||
addBtn.style.display = '';
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
addBtn?.addEventListener('click', () => showForm('', '', '', 10000, '200', 99.9, 5000));
|
||||
formCancel?.addEventListener('click', hideForm);
|
||||
|
||||
formSave?.addEventListener('click', async () => {
|
||||
const id = editingId || document.getElementById('health-form-id').value.trim();
|
||||
if (!id) return showNotification('Service ID is required', 'warning');
|
||||
const url = document.getElementById('health-form-url').value.trim();
|
||||
if (!url) return showNotification('URL is required', 'warning');
|
||||
|
||||
const codes = document.getElementById('health-form-codes').value.split(',').map(c => parseInt(c.trim())).filter(Boolean);
|
||||
const body = {
|
||||
name: document.getElementById('health-form-name').value.trim() || id,
|
||||
url,
|
||||
timeout: parseInt(document.getElementById('health-form-timeout').value) || 10000,
|
||||
expectedStatusCodes: codes.length ? codes : [200],
|
||||
sla: { target: parseFloat(document.getElementById('health-form-sla').value) || 99.9 },
|
||||
slowResponseThreshold: parseInt(document.getElementById('health-form-slow').value) || 5000
|
||||
};
|
||||
|
||||
try {
|
||||
formSave.textContent = 'Saving...';
|
||||
formSave.disabled = true;
|
||||
const res = await secureFetch(`/api/v1/health-checks/${encodeURIComponent(id)}/configure`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Save failed');
|
||||
hideForm();
|
||||
loadConfig();
|
||||
loadStatus();
|
||||
} catch (e) {
|
||||
showNotification('Error: ' + e.message, 'error');
|
||||
} finally {
|
||||
formSave.textContent = 'Save';
|
||||
formSave.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('health-edit', async (e) => {
|
||||
const id = e.detail;
|
||||
// Load existing config to populate form — use current status data as fallback
|
||||
showForm(id, '', '', 10000, '200', 99.9, 5000);
|
||||
});
|
||||
|
||||
document.addEventListener('health-delete', async (e) => {
|
||||
const id = e.detail;
|
||||
if (!confirm(`Delete health check for "${id}"?`)) return;
|
||||
try {
|
||||
const res = await secureFetch(`/api/v1/health-checks/${encodeURIComponent(id)}/configure`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error);
|
||||
loadConfig();
|
||||
loadStatus();
|
||||
} catch (err) {
|
||||
showNotification('Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal?.classList.add('show');
|
||||
loadStatus();
|
||||
});
|
||||
wireModal(modal, cancelBtn);
|
||||
refreshBtn?.addEventListener('click', loadStatus);
|
||||
|
||||
// Lazy-load tabs
|
||||
document.querySelector('[data-panel="health-incidents"]')?.addEventListener('click', loadIncidents);
|
||||
document.querySelector('[data-panel="health-config"]')?.addEventListener('click', loadConfig);
|
||||
})();
|
||||
169
status/js/import-export.js
Normal file
169
status/js/import-export.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// ========== IMPORT/EXPORT FUNCTIONALITY ==========
|
||||
(function() {
|
||||
// Export dashboard configuration
|
||||
async function exportDashboard() {
|
||||
// Fetch themes from server (source of truth) with localStorage fallback
|
||||
let userThemes = safeGetJSON('user-themes', {});
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/themes');
|
||||
const data = await res.json();
|
||||
if (data.success && data.themes) userThemes = data.themes;
|
||||
} catch (e) {}
|
||||
|
||||
const exportData = {
|
||||
version: '1.0.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
services: window.APPS || [],
|
||||
customServices: safeGetJSON('custom-services', []),
|
||||
customApps: safeGetJSON('custom-apps', []),
|
||||
weatherZip: safeGet('weather-zip') || '',
|
||||
theme: safeGet('theme') || 'dark',
|
||||
userThemes: userThemes,
|
||||
// Note: API tokens are intentionally NOT exported for security
|
||||
};
|
||||
|
||||
const dataStr = JSON.stringify(exportData, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showNotification('Dashboard exported successfully! Note: API tokens are not included for security reasons.', 'success');
|
||||
}
|
||||
|
||||
// Import dashboard configuration
|
||||
function importDashboard() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/json,.json';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const importData = JSON.parse(text);
|
||||
|
||||
// Validate import data
|
||||
if (!importData.version || !importData.services) {
|
||||
throw new Error('Invalid dashboard backup file');
|
||||
}
|
||||
|
||||
// Confirm import
|
||||
const confirmed = confirm(
|
||||
`Import dashboard configuration?\n\n` +
|
||||
`Export Date: ${new Date(importData.exportDate).toLocaleString()}\n` +
|
||||
`Services: ${importData.services.length}\n` +
|
||||
`Custom Apps: ${(importData.customApps || []).length}\n\n` +
|
||||
`⚠️ This will replace your current dashboard configuration.\n` +
|
||||
`API tokens will need to be reconfigured.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
// Import services to API
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/services', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(importData.services)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Could not save services to API, saving locally only');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('API not available, saving locally only:', err);
|
||||
}
|
||||
|
||||
// Import to localStorage
|
||||
if (importData.customServices) {
|
||||
safeSet('custom-services', JSON.stringify(importData.customServices));
|
||||
}
|
||||
if (importData.customApps) {
|
||||
safeSet('custom-apps', JSON.stringify(importData.customApps));
|
||||
}
|
||||
if (importData.weatherZip) {
|
||||
safeSet('weather-zip', importData.weatherZip);
|
||||
}
|
||||
if (importData.theme) {
|
||||
safeSet('theme', importData.theme);
|
||||
}
|
||||
if (importData.userThemes && Object.keys(importData.userThemes).length) {
|
||||
safeSet('user-themes', JSON.stringify(importData.userThemes));
|
||||
// Push imported themes to server
|
||||
Object.keys(importData.userThemes).forEach(function (slug) {
|
||||
var t = importData.userThemes[slug];
|
||||
var colors = {};
|
||||
(window.THEME_PROPS || []).forEach(function (p) { if (t[p]) colors[p] = t[p]; });
|
||||
secureFetch('/api/v1/themes/' + slug, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: t.name || slug, colors: colors })
|
||||
}).catch(function () {});
|
||||
});
|
||||
}
|
||||
|
||||
// Update APPS array
|
||||
window.APPS = importData.services;
|
||||
|
||||
showNotification('Dashboard imported successfully! The page will now reload.', 'success');
|
||||
|
||||
// Reload page to apply changes
|
||||
window.location.reload();
|
||||
|
||||
} catch (err) {
|
||||
showNotification(`Import failed: ${err.message}. Please check the file and try again.`, 'error');
|
||||
console.error('Import error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
}
|
||||
|
||||
// Add event listeners for import/export buttons
|
||||
document.getElementById('export-dashboard')?.addEventListener('click', exportDashboard);
|
||||
document.getElementById('import-dashboard')?.addEventListener('click', importDashboard);
|
||||
|
||||
// Reload Caddy button handler
|
||||
document.getElementById('reload-caddy-top')?.addEventListener('click', async () => {
|
||||
const button = document.getElementById('reload-caddy-top');
|
||||
const originalText = button.textContent;
|
||||
|
||||
try {
|
||||
button.textContent = '⏳ Reloading...';
|
||||
button.disabled = true;
|
||||
|
||||
const response = await secureFetch('/api/v1/caddy/reload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
button.textContent = '✅ Reloaded!';
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(result.error || 'Reload failed');
|
||||
}
|
||||
} catch (error) {
|
||||
button.textContent = '❌ Failed';
|
||||
showNotification(`Failed to reload Caddy: ${error.message}`, 'error');
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
622
status/js/keyboard-shortcuts.js
Normal file
622
status/js/keyboard-shortcuts.js
Normal file
@@ -0,0 +1,622 @@
|
||||
/**
|
||||
* DashCaddy Keyboard Shortcuts System
|
||||
* Provides global keyboard shortcuts for improved navigation
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// All modal selectors that can be closed with Escape
|
||||
const MODAL_SELECTORS = [
|
||||
'#app-selector-modal',
|
||||
'#app-deploy-modal',
|
||||
'#weather-modal',
|
||||
'#token-management-modal',
|
||||
'#service-edit-modal',
|
||||
'#notifications-modal',
|
||||
'#backup-modal',
|
||||
'#stats-modal',
|
||||
'#arr-setup-modal',
|
||||
'#add-service-modal',
|
||||
'#error-log-modal',
|
||||
'#logs-modal',
|
||||
'#dns-template-modal'
|
||||
];
|
||||
|
||||
// Quick search state
|
||||
let quickSearchModal = null;
|
||||
let quickSearchInput = null;
|
||||
let quickSearchResults = null;
|
||||
|
||||
/**
|
||||
* Initialize the keyboard shortcuts system
|
||||
*/
|
||||
function init() {
|
||||
try {
|
||||
// Create quick search modal
|
||||
createQuickSearchModal();
|
||||
|
||||
// Add global keyboard listener
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
console.log('[Keyboard Shortcuts] Initialized');
|
||||
console.log('[Keyboard Shortcuts] Press Ctrl+K to open quick search');
|
||||
console.log('[Keyboard Shortcuts] Press Escape to close modals');
|
||||
} catch (e) {
|
||||
console.warn('[Keyboard Shortcuts] Failed to initialize:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the quick search modal
|
||||
*/
|
||||
function createQuickSearchModal() {
|
||||
quickSearchModal = document.createElement('div');
|
||||
quickSearchModal.id = 'quick-search-modal';
|
||||
quickSearchModal.className = 'quick-search-modal';
|
||||
quickSearchModal.innerHTML = `
|
||||
<div class="quick-search-content">
|
||||
<div class="quick-search-input-wrapper">
|
||||
<span class="quick-search-icon">🔍</span>
|
||||
<input type="text" id="quick-search-input" placeholder="Search services, apps, or actions..." autocomplete="off">
|
||||
<span class="quick-search-shortcut">ESC</span>
|
||||
</div>
|
||||
<div id="quick-search-results" class="quick-search-results"></div>
|
||||
<div class="quick-search-footer">
|
||||
<span><kbd>↑↓</kbd> Navigate</span>
|
||||
<span><kbd>Enter</kbd> Select</span>
|
||||
<span><kbd>Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.quick-search-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 100000;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding-top: 15vh;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.quick-search-modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.quick-search-content {
|
||||
background: var(--card-base, #1a1a2e);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 12px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-search-icon {
|
||||
font-size: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#quick-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--fg, #fff);
|
||||
font-size: 18px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#quick-search-input::placeholder {
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
|
||||
.quick-search-shortcut {
|
||||
background: var(--card-hover, #2a2a4e);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.quick-search-results {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-search-category {
|
||||
padding: 8px 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted, #666);
|
||||
background: var(--card-hover, #2a2a4e);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.quick-search-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-search-item:hover,
|
||||
.quick-search-item.selected {
|
||||
background: var(--accent, #3498db);
|
||||
}
|
||||
|
||||
.quick-search-item-icon {
|
||||
font-size: 24px;
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-search-item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-search-item-title {
|
||||
font-weight: 500;
|
||||
color: var(--fg, #fff);
|
||||
}
|
||||
|
||||
.quick-search-item-description {
|
||||
font-size: 12px;
|
||||
color: var(--muted, #aaa);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.quick-search-item-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--card-hover, #2a2a4e);
|
||||
color: var(--muted, #888);
|
||||
}
|
||||
|
||||
.quick-search-empty {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
|
||||
.quick-search-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border, #333);
|
||||
background: var(--card-hover, #2a2a4e);
|
||||
font-size: 12px;
|
||||
color: var(--muted, #666);
|
||||
}
|
||||
|
||||
.quick-search-footer kbd {
|
||||
background: var(--card-base, #1a1a2e);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
document.body.appendChild(quickSearchModal);
|
||||
|
||||
quickSearchInput = document.getElementById('quick-search-input');
|
||||
quickSearchResults = document.getElementById('quick-search-results');
|
||||
|
||||
// Input handling
|
||||
quickSearchInput.addEventListener('input', handleSearchInput);
|
||||
quickSearchInput.addEventListener('keydown', handleSearchKeyDown);
|
||||
|
||||
// Close on backdrop click
|
||||
quickSearchModal.addEventListener('click', (e) => {
|
||||
if (e.target === quickSearchModal) {
|
||||
closeQuickSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle global keydown events
|
||||
*/
|
||||
function handleKeyDown(e) {
|
||||
try {
|
||||
// Ctrl+K or Cmd+K to open quick search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
openQuickSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape to close modals
|
||||
if (e.key === 'Escape') {
|
||||
// First check if quick search is open
|
||||
if (quickSearchModal && quickSearchModal.classList.contains('show')) {
|
||||
closeQuickSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Then close any other open modal
|
||||
closeTopModal();
|
||||
}
|
||||
} catch (e2) {
|
||||
console.warn('[Keyboard Shortcuts] Error handling keydown:', e2.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open quick search modal
|
||||
*/
|
||||
function openQuickSearch() {
|
||||
try {
|
||||
quickSearchModal.classList.add('show');
|
||||
quickSearchInput.value = '';
|
||||
quickSearchInput.focus();
|
||||
showDefaultResults();
|
||||
} catch (e) {
|
||||
console.warn('[Keyboard Shortcuts] Error opening quick search:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close quick search modal
|
||||
*/
|
||||
function closeQuickSearch() {
|
||||
try {
|
||||
quickSearchModal.classList.remove('show');
|
||||
quickSearchInput.value = '';
|
||||
quickSearchResults.innerHTML = '';
|
||||
} catch (e) {
|
||||
console.warn('[Keyboard Shortcuts] Error closing quick search:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the topmost open modal
|
||||
*/
|
||||
function closeTopModal() {
|
||||
for (const selector of MODAL_SELECTORS) {
|
||||
const modal = document.querySelector(selector);
|
||||
if (modal && (modal.classList.contains('show') || modal.style.display === 'flex')) {
|
||||
modal.classList.remove('show');
|
||||
modal.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show default/suggested results
|
||||
*/
|
||||
function showDefaultResults() {
|
||||
const html = `
|
||||
<div class="quick-search-category">Quick Actions</div>
|
||||
<div class="quick-search-item" data-action="refresh">
|
||||
<span class="quick-search-item-icon">🔄</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">Refresh Dashboard</div>
|
||||
<div class="quick-search-item-description">Refresh all service statuses</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-search-item" data-action="reload-caddy">
|
||||
<span class="quick-search-item-icon">⚡</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">Reload Caddy</div>
|
||||
<div class="quick-search-item-description">Reload Caddy configuration</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-search-item" data-action="add-service">
|
||||
<span class="quick-search-item-icon">➕</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">Add Service</div>
|
||||
<div class="quick-search-item-description">Open service configuration modal</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-search-item" data-action="app-selector">
|
||||
<span class="quick-search-item-icon">📱</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">App Selector</div>
|
||||
<div class="quick-search-item-description">Deploy new applications</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-search-category">Services</div>
|
||||
${getServiceItems()}
|
||||
`;
|
||||
|
||||
quickSearchResults.innerHTML = html;
|
||||
attachResultListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service items from the dashboard
|
||||
*/
|
||||
function getServiceItems() {
|
||||
const cards = document.querySelectorAll('.card[data-app], #cards .card');
|
||||
let html = '';
|
||||
|
||||
cards.forEach(card => {
|
||||
const name = card.querySelector('.name')?.textContent || 'Unknown';
|
||||
const status = card.dataset.status || 'unknown';
|
||||
const app = card.dataset.app || '';
|
||||
|
||||
if (name && name !== '--') {
|
||||
html += `
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${app}">
|
||||
<span class="quick-search-item-icon">${status === 'on' ? '🟢' : '🔴'}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${name}</div>
|
||||
<div class="quick-search-item-description">Click to open service</div>
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${status.toUpperCase()}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
return html || '<div class="quick-search-empty">No services found</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input
|
||||
*/
|
||||
function handleSearchInput(e) {
|
||||
try {
|
||||
const query = e.target.value.toLowerCase().trim();
|
||||
|
||||
if (!query) {
|
||||
showDefaultResults();
|
||||
return;
|
||||
}
|
||||
|
||||
// Search through services and actions
|
||||
const results = searchAll(query);
|
||||
displaySearchResults(results);
|
||||
} catch (e2) {
|
||||
console.warn('[Keyboard Shortcuts] Error handling search input:', e2.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search all items
|
||||
*/
|
||||
function searchAll(query) {
|
||||
const results = {
|
||||
actions: [],
|
||||
services: []
|
||||
};
|
||||
|
||||
// Search actions
|
||||
const actions = [
|
||||
{ id: 'refresh', title: 'Refresh Dashboard', icon: '🔄', keywords: 'refresh reload update status' },
|
||||
{ id: 'reload-caddy', title: 'Reload Caddy', icon: '⚡', keywords: 'reload caddy proxy config' },
|
||||
{ id: 'add-service', title: 'Add Service', icon: '➕', keywords: 'add new service create' },
|
||||
{ id: 'app-selector', title: 'App Selector', icon: '📱', keywords: 'app deploy install docker container' },
|
||||
{ id: 'backup', title: 'Backup & Restore', icon: '💾', keywords: 'backup restore export import' },
|
||||
{ id: 'stats', title: 'Container Stats', icon: '📊', keywords: 'stats resources cpu memory' },
|
||||
{ id: 'logs', title: 'View Logs', icon: '📋', keywords: 'logs error debug' },
|
||||
{ id: 'tokens', title: 'Manage Tokens', icon: '🔑', keywords: 'tokens api keys credentials' },
|
||||
{ id: 'notifications', title: 'Notifications', icon: '🔔', keywords: 'alerts notifications discord telegram' },
|
||||
{ id: 'theme', title: 'Change Theme', icon: '🎨', keywords: 'theme dark light appearance' },
|
||||
{ id: 'tour', title: 'Help Tour', icon: '🎓', keywords: 'help tour guide onboarding' }
|
||||
];
|
||||
|
||||
actions.forEach(action => {
|
||||
if (action.title.toLowerCase().includes(query) || action.keywords.includes(query)) {
|
||||
results.actions.push(action);
|
||||
}
|
||||
});
|
||||
|
||||
// Search services
|
||||
const cards = document.querySelectorAll('.card[data-app], #cards .card');
|
||||
cards.forEach(card => {
|
||||
const name = card.querySelector('.name')?.textContent || '';
|
||||
const app = card.dataset.app || '';
|
||||
const status = card.dataset.status || 'unknown';
|
||||
|
||||
if (name.toLowerCase().includes(query) || app.toLowerCase().includes(query)) {
|
||||
results.services.push({
|
||||
id: app,
|
||||
title: name,
|
||||
status: status,
|
||||
icon: status === 'on' ? '🟢' : '🔴'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search results
|
||||
*/
|
||||
function displaySearchResults(results) {
|
||||
let html = '';
|
||||
|
||||
if (results.actions.length > 0) {
|
||||
html += '<div class="quick-search-category">Actions</div>';
|
||||
results.actions.forEach(action => {
|
||||
html += `
|
||||
<div class="quick-search-item" data-action="${action.id}">
|
||||
<span class="quick-search-item-icon">${action.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${action.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
if (results.services.length > 0) {
|
||||
html += '<div class="quick-search-category">Services</div>';
|
||||
results.services.forEach(service => {
|
||||
html += `
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${service.id}">
|
||||
<span class="quick-search-item-icon">${service.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${service.title}</div>
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${service.status.toUpperCase()}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
html = '<div class="quick-search-empty">No results found</div>';
|
||||
}
|
||||
|
||||
quickSearchResults.innerHTML = html;
|
||||
attachResultListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach click listeners to result items
|
||||
*/
|
||||
function attachResultListeners() {
|
||||
const items = quickSearchResults.querySelectorAll('.quick-search-item');
|
||||
items.forEach((item, index) => {
|
||||
item.addEventListener('click', () => executeAction(item));
|
||||
|
||||
// Select first item by default
|
||||
if (index === 0) {
|
||||
item.classList.add('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation in search
|
||||
*/
|
||||
function handleSearchKeyDown(e) {
|
||||
try {
|
||||
const items = quickSearchResults.querySelectorAll('.quick-search-item');
|
||||
const selected = quickSearchResults.querySelector('.quick-search-item.selected');
|
||||
const selectedIndex = Array.from(items).indexOf(selected);
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (selected) selected.classList.remove('selected');
|
||||
const nextIndex = (selectedIndex + 1) % items.length;
|
||||
items[nextIndex]?.classList.add('selected');
|
||||
items[nextIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (selected) selected.classList.remove('selected');
|
||||
const prevIndex = selectedIndex <= 0 ? items.length - 1 : selectedIndex - 1;
|
||||
items[prevIndex]?.classList.add('selected');
|
||||
items[prevIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selected) {
|
||||
executeAction(selected);
|
||||
}
|
||||
}
|
||||
} catch (e2) {
|
||||
console.warn('[Keyboard Shortcuts] Error handling search navigation:', e2.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an action from quick search
|
||||
*/
|
||||
function executeAction(item) {
|
||||
try {
|
||||
const action = item.dataset.action;
|
||||
const service = item.dataset.service;
|
||||
|
||||
closeQuickSearch();
|
||||
|
||||
switch (action) {
|
||||
case 'refresh':
|
||||
document.getElementById('refresh')?.click();
|
||||
break;
|
||||
case 'reload-caddy':
|
||||
document.getElementById('reload-caddy-top')?.click();
|
||||
break;
|
||||
case 'add-service':
|
||||
document.getElementById('add-service')?.click();
|
||||
break;
|
||||
case 'app-selector':
|
||||
document.getElementById('add-service-btn')?.click();
|
||||
break;
|
||||
case 'backup':
|
||||
document.getElementById('backup-restore-btn')?.click();
|
||||
break;
|
||||
case 'stats':
|
||||
document.getElementById('container-stats-btn')?.click();
|
||||
break;
|
||||
case 'logs':
|
||||
document.getElementById('view-error-logs')?.click();
|
||||
break;
|
||||
case 'tokens':
|
||||
document.getElementById('manage-tokens')?.click();
|
||||
break;
|
||||
case 'notifications':
|
||||
document.getElementById('manage-notifications')?.click();
|
||||
break;
|
||||
case 'theme':
|
||||
document.getElementById('theme')?.click();
|
||||
break;
|
||||
case 'tour':
|
||||
document.getElementById('restart-tour-btn')?.click();
|
||||
break;
|
||||
case 'open-service':
|
||||
if (service) {
|
||||
const openBtn = document.querySelector(`[data-app="${service}"] [id$="-open"], [data-app="${service}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);
|
||||
if (openBtn) {
|
||||
openBtn.click();
|
||||
} else {
|
||||
// Try to find the card and click it
|
||||
const card = document.querySelector(`[data-app="${service}"]`);
|
||||
if (card) card.click();
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.log('[Keyboard Shortcuts] Unknown action:', action);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keyboard Shortcuts] Error executing action:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Expose to global scope
|
||||
window.DashCaddyKeyboardShortcuts = {
|
||||
openQuickSearch,
|
||||
closeQuickSearch
|
||||
};
|
||||
|
||||
})();
|
||||
278
status/js/license.js
Normal file
278
status/js/license.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// License Management UI
|
||||
(function() {
|
||||
// Inject license modal HTML
|
||||
injectModal('license-modal', `<div id="license-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>DashCaddy License</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
Activate a license code to unlock premium features.
|
||||
</p>
|
||||
|
||||
<div id="license-status-section" style="margin-bottom: 16px;">
|
||||
<div id="license-badge" style="display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; margin-bottom: 12px;">
|
||||
<span id="license-badge-icon"></span>
|
||||
<span id="license-badge-text"></span>
|
||||
</div>
|
||||
<div id="license-details" style="font-size: 0.85rem; color: var(--muted); line-height: 1.6;"></div>
|
||||
</div>
|
||||
|
||||
<div id="license-activate-section">
|
||||
<label class="form-label-bold">License Code:</label>
|
||||
<input type="text" id="license-code-input" placeholder="DC-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
maxlength="35" spellcheck="false" autocomplete="off"
|
||||
style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem; font-family: monospace; letter-spacing: 1px;" />
|
||||
<p class="tiny-hint">Enter your license code to activate premium features</p>
|
||||
</div>
|
||||
|
||||
<div id="license-features" style="margin-top: 16px;">
|
||||
<label class="form-label-bold" style="margin-bottom: 8px; display: block;">Premium Features:</label>
|
||||
<div id="license-feature-list" style="display: grid; gap: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<div id="license-error" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(231,76,60,0.15); color: var(--bad-fg); font-size: 0.85rem;"></div>
|
||||
<div id="license-success" style="display: none; margin-top: 12px; padding: 10px; border-radius: 4px; background: rgba(46,204,113,0.15); color: var(--ok-fg); font-size: 0.85rem;"></div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 16px;">
|
||||
<button id="license-cancel">Close</button>
|
||||
<button id="license-deactivate" class="btn-accent" style="display: none; background: var(--bad-bg); border-color: var(--bad-fg); color: var(--bad-fg);">Deactivate</button>
|
||||
<button id="license-activate" class="btn-accent">Activate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('license-modal');
|
||||
const codeInput = document.getElementById('license-code-input');
|
||||
const activateBtn = document.getElementById('license-activate');
|
||||
const deactivateBtn = document.getElementById('license-deactivate');
|
||||
const errorEl = document.getElementById('license-error');
|
||||
const successEl = document.getElementById('license-success');
|
||||
const badgeIcon = document.getElementById('license-badge-icon');
|
||||
const badgeText = document.getElementById('license-badge-text');
|
||||
const badge = document.getElementById('license-badge');
|
||||
const detailsEl = document.getElementById('license-details');
|
||||
const featureList = document.getElementById('license-feature-list');
|
||||
const activateSection = document.getElementById('license-activate-section');
|
||||
|
||||
let currentStatus = null;
|
||||
|
||||
function hideMessages() {
|
||||
errorEl.style.display = 'none';
|
||||
successEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
hideMessages();
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function showSuccess(msg) {
|
||||
hideMessages();
|
||||
successEl.textContent = msg;
|
||||
successEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
currentStatus = status;
|
||||
|
||||
if (status.active) {
|
||||
badge.style.background = 'rgba(46,204,113,0.15)';
|
||||
badge.style.color = 'var(--ok-fg)';
|
||||
badgeIcon.textContent = '\u2605';
|
||||
badgeText.textContent = 'Premium Active';
|
||||
const expiryLine = status.lifetime
|
||||
? '<div>License: <strong>LIFETIME</strong></div>'
|
||||
: `<div>Expires: <strong>${new Date(status.expiresAt).toLocaleDateString()}</strong> (${status.daysRemaining} days remaining)</div>`;
|
||||
detailsEl.innerHTML = `
|
||||
<div>Code: <code style="font-family: monospace;">${status.code || '***'}</code></div>
|
||||
${expiryLine}
|
||||
`;
|
||||
activateSection.style.display = 'none';
|
||||
activateBtn.style.display = 'none';
|
||||
deactivateBtn.style.display = '';
|
||||
} else {
|
||||
badge.style.background = 'rgba(149,165,166,0.15)';
|
||||
badge.style.color = 'var(--muted)';
|
||||
badgeIcon.textContent = '\u2606';
|
||||
badgeText.textContent = status.expired ? 'License Expired' : 'Free Tier';
|
||||
detailsEl.innerHTML = status.expired
|
||||
? '<div>Your license has expired. Enter a new code to renew.</div>'
|
||||
: '<div>Enter a license code to unlock premium features.</div>';
|
||||
activateSection.style.display = '';
|
||||
activateBtn.style.display = '';
|
||||
deactivateBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Render feature list
|
||||
const features = status.premiumFeatures || {};
|
||||
const activeFeatures = new Set(status.features || []);
|
||||
featureList.innerHTML = Object.entries(features).map(([key, info]) => {
|
||||
const active = activeFeatures.has(key);
|
||||
return `<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${active ? '\u2705' : '\uD83D\uDD12'}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${info.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${info.description}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/license/status');
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
renderStatus(data.license);
|
||||
updateHeaderBadge(data.license);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load license status:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function activateLicense() {
|
||||
const code = codeInput.value.trim();
|
||||
if (!code) {
|
||||
showError('Please enter a license code.');
|
||||
return;
|
||||
}
|
||||
|
||||
hideMessages();
|
||||
activateBtn.disabled = true;
|
||||
activateBtn.textContent = 'Activating...';
|
||||
|
||||
try {
|
||||
const resp = await secureFetch('/api/v1/license/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess(data.message);
|
||||
codeInput.value = '';
|
||||
renderStatus(data.license);
|
||||
showNotification('License activated! Premium features unlocked.', 'success', 5000);
|
||||
updateHeaderBadge(data.license);
|
||||
} else {
|
||||
showError(data.error || 'Activation failed');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('Network error: ' + e.message);
|
||||
} finally {
|
||||
activateBtn.disabled = false;
|
||||
activateBtn.textContent = 'Activate';
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateLicense() {
|
||||
if (!confirm('Deactivate your license? You can reuse the code on another machine.')) return;
|
||||
|
||||
deactivateBtn.disabled = true;
|
||||
deactivateBtn.textContent = 'Deactivating...';
|
||||
|
||||
try {
|
||||
const resp = await secureFetch('/api/v1/license/deactivate', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess(data.message);
|
||||
await loadStatus();
|
||||
showNotification('License deactivated.', 'info', 3000);
|
||||
updateHeaderBadge({ active: false });
|
||||
} else {
|
||||
showError(data.error || 'Deactivation failed');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('Network error: ' + e.message);
|
||||
} finally {
|
||||
deactivateBtn.disabled = false;
|
||||
deactivateBtn.textContent = 'Deactivate';
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeaderBadge(status) {
|
||||
const topbar = document.getElementById('license-status-topbar');
|
||||
const iconEl = document.getElementById('license-topbar-icon');
|
||||
const textEl = document.getElementById('license-topbar-text');
|
||||
const timeEl = document.getElementById('license-topbar-time');
|
||||
if (!topbar) return;
|
||||
|
||||
topbar.className = 'license-status-topbar ' + (status.active ? 'premium' : 'free');
|
||||
|
||||
if (status.active) {
|
||||
iconEl.textContent = '\u2605';
|
||||
textEl.textContent = 'PREMIUM';
|
||||
if (status.lifetime) {
|
||||
timeEl.textContent = '\u00b7 LIFETIME';
|
||||
} else {
|
||||
const days = status.daysRemaining;
|
||||
timeEl.textContent = days != null ? '\u00b7 ' + days + 'd remaining' : '';
|
||||
}
|
||||
} else {
|
||||
iconEl.textContent = '\u2606';
|
||||
textEl.textContent = status.expired ? 'EXPIRED' : 'FREE TIER';
|
||||
timeEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function openLicenseModal() {
|
||||
hideMessages();
|
||||
loadStatus();
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
// Format code input as user types (auto-add dashes)
|
||||
codeInput.addEventListener('input', function() {
|
||||
let val = this.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
|
||||
// Don't auto-format if user is deleting
|
||||
if (val.length > this._prevLength) {
|
||||
val = val.replace(/-/g, '');
|
||||
if (val.length > 2 && !val.startsWith('DC')) {
|
||||
val = 'DC' + val;
|
||||
}
|
||||
// Add dashes after DC and every 5 chars
|
||||
if (val.startsWith('DC') && val.length > 2) {
|
||||
const parts = ['DC'];
|
||||
const rest = val.substring(2);
|
||||
for (let i = 0; i < rest.length; i += 5) {
|
||||
parts.push(rest.substring(i, i + 5));
|
||||
}
|
||||
val = parts.join('-');
|
||||
}
|
||||
}
|
||||
this._prevLength = val.length;
|
||||
this.value = val;
|
||||
});
|
||||
|
||||
// Wire up events
|
||||
activateBtn.addEventListener('click', activateLicense);
|
||||
deactivateBtn.addEventListener('click', deactivateLicense);
|
||||
codeInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') activateLicense();
|
||||
});
|
||||
wireModal(modal, document.getElementById('license-cancel'));
|
||||
const topbarEl = document.getElementById('license-status-topbar');
|
||||
if (topbarEl) topbarEl.addEventListener('click', () => window.openLicenseModal && window.openLicenseModal());
|
||||
|
||||
// Expose for other modules to open
|
||||
window.openLicenseModal = openLicenseModal;
|
||||
|
||||
// Expose feature check for frontend gating
|
||||
window.checkPremiumFeature = async function(feature) {
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/license/feature/${feature}`);
|
||||
const data = await resp.json();
|
||||
return data.available;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load status on page load
|
||||
loadStatus().then(status => {
|
||||
if (currentStatus) updateHeaderBadge(currentStatus);
|
||||
});
|
||||
})();
|
||||
453
status/js/logo-customization.js
Normal file
453
status/js/logo-customization.js
Normal file
@@ -0,0 +1,453 @@
|
||||
// Logo Customization System
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('logo-modal', `<div id="logo-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 400px; max-width: 520px;">
|
||||
<h3>Dashboard Settings</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
Customize your dashboard's appearance and system preferences.
|
||||
</p>
|
||||
|
||||
<div class="mb-16">
|
||||
<label for="dashboard-title" class="form-label-bold">Dashboard Title:</label>
|
||||
<input type="text" id="dashboard-title" placeholder="DashCaddy" maxlength="50" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem;" />
|
||||
<p class="tiny-hint">Shown in browser tab and header (max 50 characters)</p>
|
||||
</div>
|
||||
|
||||
<hr class="hr-divider" />
|
||||
|
||||
<div class="mb-16">
|
||||
<label class="form-label-bold">Logo:</label>
|
||||
<p class="tiny-hint" style="margin-top: 2px;">Separate logos for dark and light themes, or use the same for both.</p>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
|
||||
<div style="flex: 1; text-align: center; padding: 16px 10px; border-radius: 8px; background: #1a1a2e; border: 1px solid rgba(255,255,255,.1);">
|
||||
<img id="logo-preview-dark" src="/assets/dashcaddy-logo-dark.png" alt="Dark theme logo" style="max-height: 60px; max-width: 100%;" />
|
||||
<p style="font-size: 0.65rem; color: #9aa6bf; margin-top: 6px;">Dark themes</p>
|
||||
</div>
|
||||
<div style="flex: 1; text-align: center; padding: 16px 10px; border-radius: 8px; background: #f0f0f0; border: 1px solid rgba(0,0,0,.1);">
|
||||
<img id="logo-preview-light" src="/assets/dashcaddy-logo-light.png" alt="Light theme logo" style="max-height: 60px; max-width: 100%;" />
|
||||
<p style="font-size: 0.65rem; color: #5f6b7a; margin-top: 6px;">Light themes</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="logo-status" style="font-size: 0.75rem; color: var(--muted); margin-bottom: 12px; text-align: center;">Using default logos</p>
|
||||
|
||||
<div class="mb-16">
|
||||
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.82rem; cursor: pointer; user-select: none;">
|
||||
<input type="checkbox" id="logo-same-both" /> Use same logo for both
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="logo-dual-uploads" class="mb-16" style="display: flex; gap: 12px;">
|
||||
<div style="flex: 1;">
|
||||
<label for="logo-upload-dark" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Dark theme logo:</label>
|
||||
<input type="file" id="logo-upload-dark" accept="image/*" class="input-card-alt" style="font-size: 0.75rem;" />
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<label for="logo-upload-light" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Light theme logo:</label>
|
||||
<input type="file" id="logo-upload-light" accept="image/*" class="input-card-alt" style="font-size: 0.75rem;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logo-single-upload" class="mb-16" style="display: none;">
|
||||
<label for="logo-upload-single" style="display: block; margin-bottom: 6px; font-size: 0.8rem;">Upload logo:</label>
|
||||
<input type="file" id="logo-upload-single" accept="image/*" class="input-card-alt" />
|
||||
<p class="tiny-hint">This logo will be used on all themes</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-16">
|
||||
<label style="display: block; margin-bottom: 8px;">Logo Position:</label>
|
||||
<div id="logo-position-btns" class="flex-row-gap">
|
||||
<button type="button" data-pos="left" class="logo-pos-btn btn-option">Left</button>
|
||||
<button type="button" data-pos="center" class="logo-pos-btn btn-option">Center</button>
|
||||
<button type="button" data-pos="right" class="logo-pos-btn btn-option">Right</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="hr-divider" />
|
||||
|
||||
<div class="mb-16">
|
||||
<label class="form-label-bold">Favicon (Browser Tab Icon):</label>
|
||||
<div id="favicon-preview-container" style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px; padding: 12px; border-radius: 6px; background: var(--card-bg);">
|
||||
<img id="favicon-preview" src="/assets/dashcaddy-favicon.ico" alt="Current favicon" style="width: 32px; height: 32px; image-rendering: pixelated;" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect fill=%22%23667%22 width=%2232%22 height=%2232%22 rx=%224%22/></svg>'" />
|
||||
<span id="favicon-status" class="text-tiny-muted">Using DashCaddy favicon</span>
|
||||
</div>
|
||||
<input type="file" id="favicon-upload" accept="image/png,image/svg+xml" class="input-card-alt" />
|
||||
<p class="tiny-hint">Upload PNG or SVG - automatically converted to ICO</p>
|
||||
</div>
|
||||
|
||||
<hr class="hr-divider" />
|
||||
|
||||
<div class="mb-16">
|
||||
<label for="settings-timezone" class="form-label-bold">Timezone:</label>
|
||||
<select id="settings-timezone" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-bg); color: var(--fg); font-size: 1rem;">
|
||||
<!-- Populated by JS with IANA timezones -->
|
||||
</select>
|
||||
<p class="tiny-hint">Used by all deployed containers. Changes apply to new deployments.</p>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons">
|
||||
<button id="logo-reset" style="background: color-mix(in srgb, #ef4444 20%, transparent); border-color: #ef4444; color: #ef4444;">Reset to Default</button>
|
||||
<button id="logo-cancel">Cancel</button>
|
||||
<button id="logo-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const logoModal = document.getElementById('logo-modal');
|
||||
const previewDark = document.getElementById('logo-preview-dark');
|
||||
const previewLight = document.getElementById('logo-preview-light');
|
||||
const logoStatus = document.getElementById('logo-status');
|
||||
const sameBothCheckbox = document.getElementById('logo-same-both');
|
||||
const dualUploads = document.getElementById('logo-dual-uploads');
|
||||
const singleUpload = document.getElementById('logo-single-upload');
|
||||
const uploadDark = document.getElementById('logo-upload-dark');
|
||||
const uploadLight = document.getElementById('logo-upload-light');
|
||||
const uploadSingle = document.getElementById('logo-upload-single');
|
||||
const brandLogoDark = document.querySelector('#brand .brand-logo-dark');
|
||||
const brandLogoLight = document.querySelector('#brand .brand-logo-light');
|
||||
const topRow = document.querySelector('.top-row');
|
||||
const dashboardTitleInput = document.getElementById('dashboard-title');
|
||||
const DEFAULT_TITLE = DC.NAME;
|
||||
|
||||
let pendingDarkData = null;
|
||||
let pendingLightData = null;
|
||||
let pendingSingleData = null;
|
||||
let currentPosition = 'left';
|
||||
let currentTitle = DEFAULT_TITLE;
|
||||
|
||||
// Toggle between dual and single upload mode
|
||||
sameBothCheckbox?.addEventListener('change', () => {
|
||||
if (sameBothCheckbox.checked) {
|
||||
dualUploads.style.display = 'none';
|
||||
singleUpload.style.display = '';
|
||||
// Clear individual pending data
|
||||
pendingDarkData = null;
|
||||
pendingLightData = null;
|
||||
} else {
|
||||
dualUploads.style.display = 'flex';
|
||||
singleUpload.style.display = 'none';
|
||||
pendingSingleData = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Read file as data URL helper
|
||||
function readFileAsDataURL(file, callback) {
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
showNotification('Please select an image file', 'warning');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => callback(e.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// Upload handlers
|
||||
uploadDark?.addEventListener('change', (e) => {
|
||||
readFileAsDataURL(e.target.files[0], (data) => {
|
||||
pendingDarkData = data;
|
||||
previewDark.src = data;
|
||||
logoStatus.textContent = 'New dark logo ready to save';
|
||||
});
|
||||
});
|
||||
|
||||
uploadLight?.addEventListener('change', (e) => {
|
||||
readFileAsDataURL(e.target.files[0], (data) => {
|
||||
pendingLightData = data;
|
||||
previewLight.src = data;
|
||||
logoStatus.textContent = 'New light logo ready to save';
|
||||
});
|
||||
});
|
||||
|
||||
uploadSingle?.addEventListener('change', (e) => {
|
||||
readFileAsDataURL(e.target.files[0], (data) => {
|
||||
pendingSingleData = data;
|
||||
previewDark.src = data;
|
||||
previewLight.src = data;
|
||||
logoStatus.textContent = 'New logo ready to save (both themes)';
|
||||
});
|
||||
});
|
||||
|
||||
// Apply logo position
|
||||
function applyLogoPosition(pos) {
|
||||
topRow.setAttribute('data-logo-pos', pos);
|
||||
document.querySelectorAll('.logo-pos-btn').forEach(btn => {
|
||||
btn.style.background = btn.dataset.pos === pos ? 'var(--accent)' : 'var(--card-bg)';
|
||||
btn.style.color = btn.dataset.pos === pos ? 'white' : 'var(--fg)';
|
||||
});
|
||||
}
|
||||
|
||||
// Apply dashboard title
|
||||
function applyDashboardTitle(title) {
|
||||
currentTitle = title || DEFAULT_TITLE;
|
||||
document.title = currentTitle;
|
||||
const headerTitle = document.querySelector('.dashboard-title');
|
||||
if (headerTitle) headerTitle.textContent = currentTitle;
|
||||
}
|
||||
|
||||
// Load custom logo, position, and title on startup
|
||||
async function loadCustomLogo() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/logo');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Apply dark/light variants
|
||||
if (data.customLogoDark) {
|
||||
brandLogoDark.src = data.customLogoDark;
|
||||
previewDark.src = data.customLogoDark;
|
||||
}
|
||||
if (data.customLogoLight) {
|
||||
brandLogoLight.src = data.customLogoLight;
|
||||
previewLight.src = data.customLogoLight;
|
||||
}
|
||||
// Legacy single-logo fallback
|
||||
if (!data.customLogoDark && !data.customLogoLight && data.customLogo) {
|
||||
brandLogoDark.src = data.customLogo;
|
||||
brandLogoLight.src = data.customLogo;
|
||||
previewDark.src = data.customLogo;
|
||||
previewLight.src = data.customLogo;
|
||||
}
|
||||
if (!data.isDefault) {
|
||||
logoStatus.textContent = 'Using custom logo';
|
||||
}
|
||||
if (data.position) {
|
||||
currentPosition = data.position;
|
||||
applyLogoPosition(data.position);
|
||||
}
|
||||
if (data.dashboardTitle) {
|
||||
applyDashboardTitle(data.dashboardTitle);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load custom logo:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Position button handlers
|
||||
document.querySelectorAll('.logo-pos-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
currentPosition = btn.dataset.pos;
|
||||
applyLogoPosition(currentPosition);
|
||||
});
|
||||
});
|
||||
|
||||
// Open logo modal on brand click
|
||||
document.getElementById('brand')?.addEventListener('click', () => {
|
||||
pendingDarkData = null;
|
||||
pendingLightData = null;
|
||||
pendingSingleData = null;
|
||||
if (uploadDark) uploadDark.value = '';
|
||||
if (uploadLight) uploadLight.value = '';
|
||||
if (uploadSingle) uploadSingle.value = '';
|
||||
// Reset checkbox to dual mode
|
||||
if (sameBothCheckbox) sameBothCheckbox.checked = false;
|
||||
dualUploads.style.display = 'flex';
|
||||
singleUpload.style.display = 'none';
|
||||
// Reset previews to current header logos
|
||||
previewDark.src = brandLogoDark.src;
|
||||
previewLight.src = brandLogoLight.src;
|
||||
const isCustom = brandLogoDark.src.includes('custom-logo') || brandLogoLight.src.includes('custom-logo');
|
||||
logoStatus.textContent = isCustom ? 'Using custom logo' : 'Using default logos';
|
||||
applyLogoPosition(currentPosition);
|
||||
dashboardTitleInput.value = currentTitle;
|
||||
logoModal.classList.add('show');
|
||||
});
|
||||
|
||||
// Save logo, position, and title
|
||||
document.getElementById('logo-save')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const newTitle = dashboardTitleInput.value.trim() || DEFAULT_TITLE;
|
||||
const payload = {
|
||||
position: currentPosition,
|
||||
dashboardTitle: newTitle
|
||||
};
|
||||
|
||||
// Determine which logo data to send
|
||||
if (sameBothCheckbox?.checked && pendingSingleData) {
|
||||
// Single logo for both variants
|
||||
payload.dataDark = pendingSingleData;
|
||||
payload.dataLight = pendingSingleData;
|
||||
} else {
|
||||
if (pendingDarkData) payload.dataDark = pendingDarkData;
|
||||
if (pendingLightData) payload.dataLight = pendingLightData;
|
||||
}
|
||||
|
||||
const response = await secureFetch('/api/v1/logo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const t = '?t=' + Date.now();
|
||||
if (data.pathDark) {
|
||||
brandLogoDark.src = data.pathDark + t;
|
||||
previewDark.src = data.pathDark + t;
|
||||
}
|
||||
if (data.pathLight) {
|
||||
brandLogoLight.src = data.pathLight + t;
|
||||
previewLight.src = data.pathLight + t;
|
||||
}
|
||||
applyLogoPosition(currentPosition);
|
||||
applyDashboardTitle(newTitle);
|
||||
logoModal.classList.remove('show');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification('Failed to save: ' + error.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Error saving: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset all branding to defaults
|
||||
document.getElementById('logo-reset')?.addEventListener('click', async () => {
|
||||
if (!confirm('Reset all branding to DashCaddy defaults?\n\nThis will reset the logo, favicon, title, and position.')) return;
|
||||
|
||||
try {
|
||||
const logoResponse = await secureFetch('/api/v1/logo', { method: 'DELETE' });
|
||||
if (logoResponse.ok) {
|
||||
brandLogoDark.src = '/assets/dashcaddy-logo-dark.png';
|
||||
brandLogoLight.src = '/assets/dashcaddy-logo-light.png';
|
||||
previewDark.src = '/assets/dashcaddy-logo-dark.png';
|
||||
previewLight.src = '/assets/dashcaddy-logo-light.png';
|
||||
logoStatus.textContent = 'Using default logos';
|
||||
pendingDarkData = null;
|
||||
pendingLightData = null;
|
||||
pendingSingleData = null;
|
||||
dashboardTitleInput.value = DEFAULT_TITLE;
|
||||
applyDashboardTitle(DEFAULT_TITLE);
|
||||
currentPosition = 'left';
|
||||
applyLogoPosition('left');
|
||||
}
|
||||
|
||||
const faviconResponse = await secureFetch('/api/v1/favicon', { method: 'DELETE' });
|
||||
if (faviconResponse.ok) {
|
||||
const faviconLink = document.querySelector('link[rel="icon"]');
|
||||
const faviconPreview = document.getElementById('favicon-preview');
|
||||
const faviconStatus = document.getElementById('favicon-status');
|
||||
if (faviconLink) faviconLink.href = '/assets/dashcaddy-favicon.ico?t=' + Date.now();
|
||||
if (faviconPreview) faviconPreview.src = '/assets/dashcaddy-favicon.ico?t=' + Date.now();
|
||||
if (faviconStatus) faviconStatus.textContent = 'Using DashCaddy favicon';
|
||||
pendingFaviconData = null;
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Error resetting branding: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
wireModal(logoModal, document.getElementById('logo-cancel'));
|
||||
|
||||
// ===== FAVICON HANDLING =====
|
||||
const faviconPreview = document.getElementById('favicon-preview');
|
||||
const faviconStatus = document.getElementById('favicon-status');
|
||||
const faviconUpload = document.getElementById('favicon-upload');
|
||||
const faviconLink = document.querySelector('link[rel="icon"]') || document.createElement('link');
|
||||
let pendingFaviconData = null;
|
||||
|
||||
if (!document.querySelector('link[rel="icon"]')) {
|
||||
faviconLink.rel = 'icon';
|
||||
faviconLink.href = '/assets/dashcaddy-favicon.ico';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
|
||||
async function loadCustomFavicon() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/favicon');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.customFavicon) {
|
||||
faviconLink.href = data.customFavicon + '?t=' + Date.now();
|
||||
faviconPreview.src = data.customFavicon + '?t=' + Date.now();
|
||||
faviconStatus.textContent = 'Using custom favicon';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not load custom favicon:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
faviconUpload?.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!file.type.match(/^image\/(png|svg\+xml)$/)) {
|
||||
showNotification('Please select a PNG or SVG file', 'warning');
|
||||
faviconUpload.value = '';
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
pendingFaviconData = event.target.result;
|
||||
faviconPreview.src = pendingFaviconData;
|
||||
faviconStatus.textContent = 'New favicon ready to save';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Save favicon alongside logo save
|
||||
document.getElementById('logo-save')?.addEventListener('click', async () => {
|
||||
if (pendingFaviconData) {
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/favicon', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: pendingFaviconData })
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
faviconLink.href = data.path + '?t=' + Date.now();
|
||||
faviconPreview.src = data.path + '?t=' + Date.now();
|
||||
faviconStatus.textContent = 'Using custom favicon';
|
||||
pendingFaviconData = null;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showNotification('Failed to save favicon: ' + error.error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification('Error saving favicon: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loadCustomFavicon();
|
||||
loadCustomLogo();
|
||||
|
||||
// ===== TIMEZONE SETTING =====
|
||||
const settingsTzSelect = document.getElementById('settings-timezone');
|
||||
if (settingsTzSelect) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (logoModal.classList.contains('show') && settingsTzSelect.options.length === 0) {
|
||||
(async () => {
|
||||
let currentTz;
|
||||
try {
|
||||
const res = await fetch('/api/v1/config');
|
||||
if (res.ok) { const cfg = await res.json(); currentTz = cfg.timezone; }
|
||||
} catch (e) { /* ignore */ }
|
||||
window.populateTimezoneSelect(settingsTzSelect, currentTz);
|
||||
})();
|
||||
}
|
||||
});
|
||||
observer.observe(logoModal, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
document.getElementById('logo-save')?.addEventListener('click', async () => {
|
||||
const tz = settingsTzSelect.value;
|
||||
if (!tz) return;
|
||||
try {
|
||||
const res = await fetch('/api/v1/config');
|
||||
if (!res.ok) return;
|
||||
const cfg = await res.json();
|
||||
cfg.timezone = tz;
|
||||
cfg.updatedAt = new Date().toISOString();
|
||||
await secureFetch('/api/v1/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to save timezone:', e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
344
status/js/notification-settings.js
Normal file
344
status/js/notification-settings.js
Normal file
@@ -0,0 +1,344 @@
|
||||
// ========== NOTIFICATION SETTINGS ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('notifications-modal', `<div id="notifications-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>🔔 Notification Settings</h3>
|
||||
|
||||
<!-- Master Toggle -->
|
||||
<div class="accent-info-box">
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
||||
<input type="checkbox" id="notifications-enabled" />
|
||||
<div>
|
||||
<span style="font-weight: 600; color: var(--accent);">Enable Notifications</span>
|
||||
<div class="text-tiny-muted">Receive alerts when containers go up/down</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Providers Section -->
|
||||
<h4 class="section-heading">Notification Providers</h4>
|
||||
|
||||
<!-- Discord -->
|
||||
<div class="notification-provider provider-card">
|
||||
<div class="provider-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="discord-enabled" />
|
||||
<span class="fw-500">Discord</span>
|
||||
</label>
|
||||
<button id="discord-test" class="test-btn btn-xs">Test</button>
|
||||
</div>
|
||||
<div id="discord-config" style="display: none;">
|
||||
<label class="field-label-sm">Webhook URL:</label>
|
||||
<input type="text" id="discord-webhook" placeholder="https://discord.com/api/v1/webhooks/..." style="width: 100%;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram -->
|
||||
<div class="notification-provider provider-card">
|
||||
<div class="provider-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="telegram-enabled" />
|
||||
<span class="fw-500">Telegram</span>
|
||||
</label>
|
||||
<button id="telegram-test" class="test-btn btn-xs">Test</button>
|
||||
</div>
|
||||
<div id="telegram-config" style="display: none;">
|
||||
<div class="grid-2col">
|
||||
<div>
|
||||
<label class="field-label-sm">Bot Token:</label>
|
||||
<input type="text" id="telegram-bot-token" placeholder="123456:ABC..." />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label-sm">Chat ID:</label>
|
||||
<input type="text" id="telegram-chat-id" placeholder="-1001234567890" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ntfy.sh -->
|
||||
<div class="notification-provider provider-card">
|
||||
<div class="provider-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="ntfy-enabled" />
|
||||
<span class="fw-500">ntfy.sh</span>
|
||||
</label>
|
||||
<button id="ntfy-test" class="test-btn btn-xs">Test</button>
|
||||
</div>
|
||||
<div id="ntfy-config" style="display: none;">
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label class="field-label-sm">Server URL:</label>
|
||||
<input type="text" id="ntfy-server" placeholder="https://ntfy.sh" value="https://ntfy.sh" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label-sm">Topic:</label>
|
||||
<input type="text" id="ntfy-topic" placeholder="dashcaddy-alerts" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Check Settings -->
|
||||
<h4 class="section-heading">Health Monitoring</h4>
|
||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
|
||||
<input type="checkbox" id="health-check-enabled" />
|
||||
<div>
|
||||
<span class="fw-500">Enable Health Monitoring</span>
|
||||
<div class="text-tiny-muted">Periodically check container status</div>
|
||||
</div>
|
||||
</label>
|
||||
<div id="health-check-config" style="display: flex; align-items: center; gap: 10px;">
|
||||
<label class="field-label-sm">Check interval:</label>
|
||||
<select id="health-check-interval" style="width: auto;">
|
||||
<option value="1">1 minute</option>
|
||||
<option value="5" selected>5 minutes</option>
|
||||
<option value="15">15 minutes</option>
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="60">1 hour</option>
|
||||
</select>
|
||||
<button id="health-check-now" style="padding: 4px 10px; font-size: 0.75rem; margin-left: auto;">Check Now</button>
|
||||
</div>
|
||||
<div id="health-check-status" style="font-size: 0.75rem; color: var(--muted); margin-top: 8px;">
|
||||
Last check: Never
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Types -->
|
||||
<h4 class="section-heading">Events to Notify</h4>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox" id="event-container-down" checked /> Container Down
|
||||
</label>
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox" id="event-container-up" checked /> Container Recovered
|
||||
</label>
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox" id="event-deploy-success" checked /> Deployment Success
|
||||
</label>
|
||||
<label class="checkbox-label-sm">
|
||||
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<h4 class="section-heading">Notification History</h4>
|
||||
<div id="notification-history" style="max-height: 150px; overflow-y: auto; padding: 8px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); font-size: 0.8rem;">
|
||||
<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="notifications-cancel">Cancel</button>
|
||||
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('notifications-modal');
|
||||
const openBtn = document.getElementById('manage-notifications');
|
||||
const saveBtn = document.getElementById('notifications-save');
|
||||
const cancelBtn = document.getElementById('notifications-cancel');
|
||||
|
||||
// Provider toggle handlers
|
||||
['discord', 'telegram', 'ntfy'].forEach(provider => {
|
||||
const checkbox = document.getElementById(`${provider}-enabled`);
|
||||
const config = document.getElementById(`${provider}-config`);
|
||||
|
||||
checkbox?.addEventListener('change', () => {
|
||||
config.style.display = checkbox.checked ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Health check toggle
|
||||
const healthCheckEnabled = document.getElementById('health-check-enabled');
|
||||
const healthCheckConfig = document.getElementById('health-check-config');
|
||||
healthCheckEnabled?.addEventListener('change', () => {
|
||||
healthCheckConfig.style.opacity = healthCheckEnabled.checked ? '1' : '0.5';
|
||||
});
|
||||
|
||||
// Load notification config from API
|
||||
async function loadNotificationConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notifications/config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const config = data.config;
|
||||
|
||||
// Master toggle
|
||||
document.getElementById('notifications-enabled').checked = config.enabled;
|
||||
|
||||
// Providers
|
||||
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
|
||||
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
|
||||
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false;
|
||||
|
||||
// Show/hide config sections
|
||||
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none';
|
||||
document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
|
||||
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none';
|
||||
|
||||
// ntfy server URL
|
||||
if (config.providers?.ntfy?.serverUrl) {
|
||||
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
||||
}
|
||||
|
||||
// Health check
|
||||
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
||||
if (config.healthCheck?.intervalMinutes) {
|
||||
document.getElementById('health-check-interval').value = config.healthCheck.intervalMinutes;
|
||||
}
|
||||
if (config.healthCheck?.lastCheck) {
|
||||
document.getElementById('health-check-status').textContent =
|
||||
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
|
||||
}
|
||||
|
||||
// Events
|
||||
document.getElementById('event-container-down').checked = config.events?.containerDown !== false;
|
||||
document.getElementById('event-container-up').checked = config.events?.containerUp !== false;
|
||||
document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false;
|
||||
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load notification config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load notification history
|
||||
async function loadNotificationHistory() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/notifications/history?limit=10');
|
||||
const data = await response.json();
|
||||
|
||||
const container = document.getElementById('notification-history');
|
||||
if (data.success && data.history?.length > 0) {
|
||||
container.innerHTML = data.history.map(item => {
|
||||
const date = new Date(item.timestamp).toLocaleString();
|
||||
const typeColors = {
|
||||
success: 'var(--ok-fg)',
|
||||
error: 'var(--bad-fg)',
|
||||
warning: '#f39c12',
|
||||
info: 'var(--accent)'
|
||||
};
|
||||
return `
|
||||
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
|
||||
<span style="color: ${typeColors[item.type] || 'var(--muted)'}">${item.type === 'success' ? '✓' : item.type === 'error' ? '✗' : 'ℹ'}</span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(item.title)}</div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted);">${date}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
container.innerHTML = '<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load notification history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save notification config
|
||||
async function saveNotificationConfig() {
|
||||
try {
|
||||
const config = {
|
||||
enabled: document.getElementById('notifications-enabled').checked,
|
||||
providers: {
|
||||
discord: {
|
||||
enabled: document.getElementById('discord-enabled').checked,
|
||||
webhookUrl: document.getElementById('discord-webhook').value.trim()
|
||||
},
|
||||
telegram: {
|
||||
enabled: document.getElementById('telegram-enabled').checked,
|
||||
botToken: document.getElementById('telegram-bot-token').value.trim(),
|
||||
chatId: document.getElementById('telegram-chat-id').value.trim()
|
||||
},
|
||||
ntfy: {
|
||||
enabled: document.getElementById('ntfy-enabled').checked,
|
||||
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
|
||||
topic: document.getElementById('ntfy-topic').value.trim()
|
||||
}
|
||||
},
|
||||
events: {
|
||||
containerDown: document.getElementById('event-container-down').checked,
|
||||
containerUp: document.getElementById('event-container-up').checked,
|
||||
deploymentSuccess: document.getElementById('event-deploy-success').checked,
|
||||
deploymentFailed: document.getElementById('event-deploy-failed').checked
|
||||
},
|
||||
healthCheck: {
|
||||
enabled: document.getElementById('health-check-enabled').checked,
|
||||
intervalMinutes: parseInt(document.getElementById('health-check-interval').value) || 5
|
||||
}
|
||||
};
|
||||
|
||||
const response = await secureFetch('/api/v1/notifications/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification('Notification settings saved', 'success', 3000);
|
||||
modal.classList.remove('show');
|
||||
} else {
|
||||
showNotification(`Failed to save: ${data.error}`, 'error', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(`Error: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test notification handlers
|
||||
async function testProvider(provider) {
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification(`Test ${provider} notification sent!`, 'success', 3000);
|
||||
} else {
|
||||
showNotification(`Test failed: ${data.error}`, 'error', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(`Error: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
|
||||
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
|
||||
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
|
||||
|
||||
// Health check now button
|
||||
document.getElementById('health-check-now')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/notifications/health-check', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('health-check-status').textContent =
|
||||
`Last check: ${new Date(data.lastCheck).toLocaleString()} (${data.containersMonitored} containers)`;
|
||||
showNotification('Health check completed', 'success', 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
showNotification(`Error: ${error.message}`, 'error', 3000);
|
||||
}
|
||||
});
|
||||
|
||||
// Modal handlers
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
loadNotificationConfig();
|
||||
loadNotificationHistory();
|
||||
});
|
||||
|
||||
saveBtn?.addEventListener('click', saveNotificationConfig);
|
||||
wireModal(modal, cancelBtn);
|
||||
})();
|
||||
177
status/js/onboarding.js
Normal file
177
status/js/onboarding.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* DashCaddy User Onboarding System
|
||||
* Main entry point for the tooltip-based onboarding experience
|
||||
*
|
||||
* This file initializes the onboarding system and coordinates between
|
||||
* the various components (TourManager, ProgressTracker, ThemeAdapter, etc.)
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let progressTracker;
|
||||
let themeAdapter;
|
||||
let tourManager;
|
||||
let dnsTemplateSelector;
|
||||
let errorHandler;
|
||||
|
||||
/**
|
||||
* Initialize the onboarding system
|
||||
*/
|
||||
async function initializeOnboarding() {
|
||||
try {
|
||||
console.log('[Onboarding] Initializing system...');
|
||||
|
||||
// Initialize Error Handler first
|
||||
errorHandler = new ErrorHandler();
|
||||
console.log('[Onboarding] Error Handler initialized');
|
||||
|
||||
// Initialize Progress Tracker
|
||||
progressTracker = new ProgressTracker('dashcaddy_onboarding');
|
||||
console.log('[Onboarding] Progress Tracker initialized');
|
||||
|
||||
// Initialize Theme Adapter
|
||||
themeAdapter = new ThemeAdapter();
|
||||
console.log('[Onboarding] Theme Adapter initialized');
|
||||
|
||||
// Initialize DNS Template Selector
|
||||
dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
|
||||
console.log('[Onboarding] DNS Template Selector initialized');
|
||||
|
||||
// Initialize Tour Manager
|
||||
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
|
||||
console.log('[Onboarding] Tour Manager initialized');
|
||||
|
||||
// Check if tour should auto-start
|
||||
if (tourManager.shouldAutoStart()) {
|
||||
console.log('[Onboarding] Auto-starting tour for first-time user');
|
||||
// Wait a bit for page to fully load
|
||||
setTimeout(() => {
|
||||
tourManager.startTour();
|
||||
}, 1000);
|
||||
} else {
|
||||
const tourCompleted = progressTracker.isTourCompleted();
|
||||
const currentStep = progressTracker.getCurrentStep();
|
||||
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
|
||||
|
||||
// If tour is in progress, offer to resume
|
||||
if (!tourCompleted && currentStep > 0) {
|
||||
console.log('[Onboarding] Tour in progress, can be resumed manually');
|
||||
}
|
||||
}
|
||||
|
||||
// Add restart tour button to tools row
|
||||
addRestartTourButton();
|
||||
|
||||
// Expose to global scope for manual triggering
|
||||
window.DashCaddyOnboarding = {
|
||||
startTour: () => tourManager.startTour(),
|
||||
restartTour: () => tourManager.restartTour(),
|
||||
showTooltip: (id) => tourManager.showTooltip(id),
|
||||
showWhatsNew: () => tourManager.showWhatsNew(),
|
||||
resetProgress: () => progressTracker.resetProgress(),
|
||||
getErrors: () => errorHandler.getErrors(),
|
||||
getErrorStats: () => errorHandler.getStatistics()
|
||||
};
|
||||
|
||||
console.log('[Onboarding] System initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[Onboarding] Initialization error:', error);
|
||||
|
||||
// Use error handler if available
|
||||
if (errorHandler) {
|
||||
errorHandler.logError('Initialization', error);
|
||||
}
|
||||
|
||||
// Graceful degradation - don't break the dashboard
|
||||
console.warn('[Onboarding] System failed to initialize, dashboard will continue without onboarding');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add restart tour button to tools row
|
||||
*/
|
||||
function addRestartTourButton() {
|
||||
const toolsRow = document.querySelector('.tools-primary') || document.querySelector('.tools');
|
||||
if (!toolsRow) return;
|
||||
|
||||
const clickHandler = () => {
|
||||
if (tourManager) {
|
||||
console.log('[Onboarding] Starting tour via button click');
|
||||
tourManager.restartTour();
|
||||
} else {
|
||||
console.error('[Onboarding] Tour manager not initialized');
|
||||
alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization');
|
||||
}
|
||||
};
|
||||
|
||||
// If button already exists in the HTML, just attach the handler
|
||||
const existing = document.getElementById('restart-tour-btn');
|
||||
if (existing) {
|
||||
existing.onclick = clickHandler;
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.id = 'restart-tour-btn';
|
||||
button.textContent = 'Help Tour';
|
||||
button.title = 'Restart the onboarding tour';
|
||||
button.onclick = clickHandler;
|
||||
toolsRow.appendChild(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Driver.js is loaded
|
||||
*/
|
||||
function checkDriverLoaded() {
|
||||
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
|
||||
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
|
||||
if (typeof driverFactory !== 'function') {
|
||||
console.warn('[Onboarding] Driver.js not loaded yet, will retry... window.driver:', window.driver);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Driver.js to load, then initialize
|
||||
*/
|
||||
function waitForDriver() {
|
||||
let retries = 0;
|
||||
const maxRetries = 10;
|
||||
|
||||
function attemptInit() {
|
||||
if (checkDriverLoaded()) {
|
||||
initializeOnboarding();
|
||||
} else {
|
||||
retries++;
|
||||
if (retries < maxRetries) {
|
||||
// Retry after a short delay
|
||||
setTimeout(attemptInit, 500);
|
||||
} else {
|
||||
// Max retries reached, show fallback
|
||||
console.error('[Onboarding] Driver.js failed to load after multiple attempts');
|
||||
if (errorHandler) {
|
||||
errorHandler.handleDriverLoadFailure();
|
||||
} else {
|
||||
// Create temporary error handler for fallback
|
||||
const tempHandler = new ErrorHandler();
|
||||
tempHandler.handleDriverLoadFailure();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attemptInit();
|
||||
}
|
||||
|
||||
// Start initialization when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', waitForDriver);
|
||||
} else {
|
||||
waitForDriver();
|
||||
}
|
||||
|
||||
console.log('[Onboarding] System loaded');
|
||||
|
||||
})();
|
||||
16
status/js/panel-tabs.js
Normal file
16
status/js/panel-tabs.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// ========== PANEL TAB SWITCHING (shared utility) ==========
|
||||
(function() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const tab = e.target.closest('.panel-tab');
|
||||
if (!tab) return;
|
||||
const panelId = tab.dataset.panel;
|
||||
if (!panelId) return;
|
||||
const tabBar = tab.closest('.panel-tabs');
|
||||
const modalContent = tabBar.closest('.weather-modal-content');
|
||||
tabBar.querySelectorAll('.panel-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
modalContent.querySelectorAll('.panel-section').forEach(s => s.classList.remove('active'));
|
||||
const target = modalContent.querySelector('#' + panelId);
|
||||
if (target) target.classList.add('active');
|
||||
});
|
||||
})();
|
||||
282
status/js/progress-tracker.js
Normal file
282
status/js/progress-tracker.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Progress Tracker
|
||||
* Manages persistent storage of user progress through the onboarding flow
|
||||
* using browser local storage.
|
||||
*
|
||||
* Storage Schema:
|
||||
* {
|
||||
* "version": "1.0",
|
||||
* "tourCompleted": false,
|
||||
* "completedTooltips": ["welcome", "dns-priority", ...],
|
||||
* "currentStep": 3,
|
||||
* "completionTimestamp": "2024-01-15T10:30:00Z",
|
||||
* "dnsSetupDeferred": false,
|
||||
* "lastVisit": "2024-01-15T10:30:00Z"
|
||||
* }
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ProgressTracker class
|
||||
* Manages persistent storage of onboarding progress
|
||||
*
|
||||
* @class
|
||||
* @param {string} storageKey - The key to use for local storage (default: 'dashcaddy_onboarding')
|
||||
*/
|
||||
class ProgressTracker {
|
||||
constructor(storageKey = 'dashcaddy_onboarding') {
|
||||
this.storageKey = storageKey;
|
||||
this.storageVersion = '1.0';
|
||||
|
||||
// Initialize storage if it doesn't exist
|
||||
this._initializeStorage();
|
||||
|
||||
// Update last visit timestamp
|
||||
this._updateLastVisit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize storage with default values if it doesn't exist
|
||||
* @private
|
||||
*/
|
||||
_initializeStorage() {
|
||||
const existing = this._getStorage();
|
||||
if (!existing || existing.version !== this.storageVersion) {
|
||||
const defaultState = {
|
||||
version: this.storageVersion,
|
||||
tourCompleted: false,
|
||||
completedTooltips: [],
|
||||
currentStep: 0,
|
||||
completionTimestamp: null,
|
||||
dnsSetupDeferred: false,
|
||||
lastVisit: new Date().toISOString()
|
||||
};
|
||||
this._setStorage(defaultState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current storage state
|
||||
* @private
|
||||
* @returns {Object|null} The storage state or null if unavailable
|
||||
*/
|
||||
_getStorage() {
|
||||
try {
|
||||
const data = localStorage.getItem(this.storageKey);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.error('[ProgressTracker] Error reading from storage:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the storage state
|
||||
* @private
|
||||
* @param {Object} state - The state to save
|
||||
*/
|
||||
_setStorage(state) {
|
||||
try {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.error('[ProgressTracker] Error writing to storage:', error);
|
||||
// Handle quota exceeded or storage unavailable
|
||||
// Fall back to session storage or in-memory storage
|
||||
this._handleStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle storage errors (quota exceeded, unavailable, etc.)
|
||||
* @private
|
||||
* @param {Error} error - The error that occurred
|
||||
*/
|
||||
_handleStorageError(error) {
|
||||
// Try session storage as fallback
|
||||
try {
|
||||
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
|
||||
console.warn('[ProgressTracker] Falling back to session storage');
|
||||
} catch (sessionError) {
|
||||
console.error('[ProgressTracker] Session storage also unavailable:', sessionError);
|
||||
// Could implement in-memory fallback here if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last visit timestamp
|
||||
* @private
|
||||
*/
|
||||
_updateLastVisit() {
|
||||
const state = this._getStorage();
|
||||
if (state) {
|
||||
state.lastVisit = new Date().toISOString();
|
||||
this._setStorage(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tooltip has been completed
|
||||
* @param {string} tooltipId - The ID of the tooltip to check
|
||||
* @returns {boolean} True if the tooltip has been completed
|
||||
*/
|
||||
isTooltipCompleted(tooltipId) {
|
||||
const state = this._getStorage();
|
||||
if (!state) return false;
|
||||
return state.completedTooltips.includes(tooltipId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a tooltip as completed with timestamp
|
||||
* @param {string} tooltipId - The ID of the tooltip to mark as completed
|
||||
*/
|
||||
markTooltipCompleted(tooltipId) {
|
||||
const state = this._getStorage();
|
||||
if (!state) return;
|
||||
|
||||
// Add tooltip to completed list if not already there
|
||||
if (!state.completedTooltips.includes(tooltipId)) {
|
||||
state.completedTooltips.push(tooltipId);
|
||||
|
||||
// Store timestamp for this specific tooltip
|
||||
if (!state.tooltipTimestamps) {
|
||||
state.tooltipTimestamps = {};
|
||||
}
|
||||
state.tooltipTimestamps[tooltipId] = new Date().toISOString();
|
||||
|
||||
this._setStorage(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the entire tour has been completed
|
||||
* @returns {boolean} True if the tour is completed
|
||||
*/
|
||||
isTourCompleted() {
|
||||
const state = this._getStorage();
|
||||
if (!state) return false;
|
||||
return state.tourCompleted === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the entire tour as completed
|
||||
*/
|
||||
markTourCompleted() {
|
||||
const state = this._getStorage();
|
||||
if (!state) return;
|
||||
|
||||
state.tourCompleted = true;
|
||||
state.completionTimestamp = new Date().toISOString();
|
||||
this._setStorage(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current step index
|
||||
* @returns {number} The current step index (0-based)
|
||||
*/
|
||||
getCurrentStep() {
|
||||
const state = this._getStorage();
|
||||
if (!state) return 0;
|
||||
return state.currentStep || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current step index
|
||||
* @param {number} stepIndex - The step index to set (0-based)
|
||||
*/
|
||||
setCurrentStep(stepIndex) {
|
||||
const state = this._getStorage();
|
||||
if (!state) return;
|
||||
|
||||
state.currentStep = stepIndex;
|
||||
this._setStorage(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all progress and clear storage
|
||||
*/
|
||||
resetProgress() {
|
||||
const defaultState = {
|
||||
version: this.storageVersion,
|
||||
tourCompleted: false,
|
||||
completedTooltips: [],
|
||||
currentStep: 0,
|
||||
completionTimestamp: null,
|
||||
dnsSetupDeferred: false,
|
||||
lastVisit: new Date().toISOString()
|
||||
};
|
||||
this._setStorage(defaultState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the completion timestamp
|
||||
* @returns {Date|null} The completion timestamp or null if not completed
|
||||
*/
|
||||
getCompletionTimestamp() {
|
||||
const state = this._getStorage();
|
||||
if (!state || !state.completionTimestamp) return null;
|
||||
return new Date(state.completionTimestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if DNS setup was deferred
|
||||
* @returns {boolean} True if DNS setup was deferred
|
||||
*/
|
||||
isDnsSetupDeferred() {
|
||||
const state = this._getStorage();
|
||||
if (!state) return false;
|
||||
return state.dnsSetupDeferred === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark DNS setup as deferred
|
||||
*/
|
||||
markDnsSetupDeferred() {
|
||||
const state = this._getStorage();
|
||||
if (!state) return;
|
||||
|
||||
state.dnsSetupDeferred = true;
|
||||
this._setStorage(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp for a specific tooltip completion
|
||||
* @param {string} tooltipId - The ID of the tooltip
|
||||
* @returns {Date|null} The timestamp or null if not completed
|
||||
*/
|
||||
getTooltipTimestamp(tooltipId) {
|
||||
const state = this._getStorage();
|
||||
if (!state || !state.tooltipTimestamps || !state.tooltipTimestamps[tooltipId]) {
|
||||
return null;
|
||||
}
|
||||
return new Date(state.tooltipTimestamps[tooltipId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all completed tooltip IDs
|
||||
* @returns {string[]} Array of completed tooltip IDs
|
||||
*/
|
||||
getCompletedTooltips() {
|
||||
const state = this._getStorage();
|
||||
if (!state) return [];
|
||||
return state.completedTooltips || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last visit timestamp
|
||||
* @returns {Date|null} The last visit timestamp
|
||||
*/
|
||||
getLastVisit() {
|
||||
const state = this._getStorage();
|
||||
if (!state || !state.lastVisit) return null;
|
||||
return new Date(state.lastVisit);
|
||||
}
|
||||
}
|
||||
|
||||
// Export to global scope
|
||||
window.ProgressTracker = ProgressTracker;
|
||||
|
||||
console.log('[ProgressTracker] Module loaded');
|
||||
|
||||
})(window);
|
||||
590
status/js/recipes.js
Normal file
590
status/js/recipes.js
Normal file
@@ -0,0 +1,590 @@
|
||||
// Recipe System — multi-container stack deployment
|
||||
(function() {
|
||||
// === RECIPE DEPLOY WIZARD MODAL ===
|
||||
injectModal('recipe-deploy-modal', `<div id="recipe-deploy-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
|
||||
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div id="recipe-steps" style="display: flex; gap: 4px; margin: 16px 0 24px;">
|
||||
<div class="recipe-step active" data-step="1"><span>1</span> Components</div>
|
||||
<div class="recipe-step" data-step="2"><span>2</span> Configuration</div>
|
||||
<div class="recipe-step" data-step="3"><span>3</span> Review</div>
|
||||
<div class="recipe-step" data-step="4"><span>4</span> Progress</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Component Selection -->
|
||||
<div id="recipe-step-1" class="recipe-step-panel">
|
||||
<label class="form-label-accent-sm">Select Components:</label>
|
||||
<div id="recipe-component-list" style="display: grid; gap: 10px; margin-top: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Shared Configuration -->
|
||||
<div id="recipe-step-2" class="recipe-step-panel" style="display:none;">
|
||||
<div style="display: grid; gap: 16px;">
|
||||
<!-- Shared volumes -->
|
||||
<div id="recipe-volumes-section" style="display:none;">
|
||||
<label class="form-label-accent-sm">Shared Volumes:</label>
|
||||
<div id="recipe-volume-list" style="display: grid; gap: 10px; margin-top: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Timezone -->
|
||||
<div>
|
||||
<label class="form-label-accent-sm">Timezone:</label>
|
||||
<input type="text" id="recipe-timezone" value="UTC" placeholder="e.g. America/New_York"
|
||||
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
</div>
|
||||
|
||||
<!-- Target IP -->
|
||||
<div>
|
||||
<label class="form-label-accent-sm">Target IP Address:</label>
|
||||
<input type="text" id="recipe-ip" value="host.docker.internal" placeholder="localhost or IP"
|
||||
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px;" />
|
||||
<div class="form-hint-sm">IP where containers expose ports</div>
|
||||
</div>
|
||||
|
||||
<!-- Tailscale -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="recipe-tailscale" />
|
||||
<span>Restrict to Tailscale network only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Review -->
|
||||
<div id="recipe-step-3" class="recipe-step-panel" style="display:none;">
|
||||
<div id="recipe-review-content" style="font-size: 0.9rem; line-height: 1.7;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Progress -->
|
||||
<div id="recipe-step-4" class="recipe-step-panel" style="display:none;">
|
||||
<div id="recipe-progress-list" style="display: grid; gap: 8px;"></div>
|
||||
<div id="recipe-deploy-result" style="margin-top: 16px; display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 20px;">
|
||||
<button id="recipe-cancel">Cancel</button>
|
||||
<button id="recipe-prev" class="btn-accent" style="display:none;">Back</button>
|
||||
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
// === STATE ===
|
||||
let recipeTemplates = null;
|
||||
let recipeCategories = null;
|
||||
let currentRecipe = null;
|
||||
let currentStep = 1;
|
||||
let isPremium = false;
|
||||
|
||||
const deployModal = document.getElementById('recipe-deploy-modal');
|
||||
const cancelBtn = document.getElementById('recipe-cancel');
|
||||
const prevBtn = document.getElementById('recipe-prev');
|
||||
const nextBtn = document.getElementById('recipe-next');
|
||||
|
||||
wireModal(deployModal, cancelBtn);
|
||||
|
||||
// === FETCH RECIPE TEMPLATES ===
|
||||
async function fetchRecipeTemplates() {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/recipes/templates');
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
recipeTemplates = data.templates;
|
||||
recipeCategories = data.categories;
|
||||
return true;
|
||||
}
|
||||
// Premium required — templates API gated
|
||||
if (resp.status === 403) {
|
||||
isPremium = false;
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch recipe templates:', e.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// === CHECK PREMIUM ===
|
||||
async function checkRecipePremium() {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/license/feature/recipes');
|
||||
const data = await resp.json();
|
||||
isPremium = data.available;
|
||||
} catch {
|
||||
isPremium = false;
|
||||
}
|
||||
return isPremium;
|
||||
}
|
||||
|
||||
// === RENDER RECIPE CARDS INTO APP SELECTOR ===
|
||||
// This function is called by the app selector to inject recipe cards
|
||||
window.renderRecipeCards = async function(grid) {
|
||||
await checkRecipePremium();
|
||||
|
||||
// Build recipe data — we can show card metadata even without premium
|
||||
// (cards just show a lock and prompt to upgrade)
|
||||
let recipes;
|
||||
if (isPremium && recipeTemplates) {
|
||||
recipes = recipeTemplates;
|
||||
} else {
|
||||
// Show hardcoded preview cards when not premium
|
||||
recipes = getPreviewRecipes();
|
||||
}
|
||||
|
||||
if (!recipes || recipes.length === 0) return;
|
||||
|
||||
// Category header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'app-category-header';
|
||||
header.innerHTML = `\uD83E\uDDEA Recipes`;
|
||||
header.style.borderBottomColor = '#8e44ad';
|
||||
grid.appendChild(header);
|
||||
|
||||
const recipeList = Array.isArray(recipes) ? recipes : Object.values(recipes);
|
||||
recipeList.sort((a, b) => (b.popularity || 0) - (a.popularity || 0));
|
||||
|
||||
for (const recipe of recipeList) {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'app-option';
|
||||
option.style.position = 'relative';
|
||||
|
||||
const componentBadge = `<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${recipe.componentCount || recipe.components?.length || '?'} apps</div>`;
|
||||
|
||||
const lockOverlay = !isPremium ? `<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>` : '';
|
||||
|
||||
option.innerHTML = `
|
||||
${lockOverlay}
|
||||
<div class="app-option-icon">${escapeHtml(recipe.icon || '\uD83E\uDDEA')}</div>
|
||||
<div class="app-option-name">${escapeHtml(recipe.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(recipe.description || '')}</div>
|
||||
${componentBadge}
|
||||
`;
|
||||
|
||||
option.onclick = () => {
|
||||
if (!isPremium) {
|
||||
showNotification('Recipes require a DashCaddy Premium license. Click the License button to activate.', 'warning', 5000);
|
||||
if (window.openLicenseModal) window.openLicenseModal();
|
||||
return;
|
||||
}
|
||||
openRecipeDeployWizard(recipe);
|
||||
};
|
||||
|
||||
grid.appendChild(option);
|
||||
}
|
||||
};
|
||||
|
||||
function getPreviewRecipes() {
|
||||
return [
|
||||
{ id: 'htpc-suite', name: 'HTPC Suite', icon: '\uD83C\uDFAC', description: 'Complete media automation: find, download, organize, and stream', componentCount: 6, popularity: 98 },
|
||||
{ id: 'nextcloud-complete', name: 'Nextcloud Complete', icon: '\u2601\uFE0F', description: 'Full productivity suite: cloud storage, office editing, and collaboration', componentCount: 4, popularity: 90 },
|
||||
{ id: 'smart-home', name: 'Smart Home Hub', icon: '\uD83C\uDFE0', description: 'Home automation: control, automate, and monitor IoT devices', componentCount: 4, popularity: 88 },
|
||||
{ id: 'dev-environment', name: 'Dev Environment', icon: '\uD83D\uDCBB', description: 'Self-hosted development workflow: Git, CI/CD, IDE, and database', componentCount: 4, popularity: 82 }
|
||||
];
|
||||
}
|
||||
|
||||
// === RECIPE DEPLOY WIZARD ===
|
||||
function openRecipeDeployWizard(recipe) {
|
||||
currentRecipe = recipe;
|
||||
currentStep = 1;
|
||||
|
||||
// Close app selector
|
||||
const appSelectorModal = document.getElementById('app-selector-modal');
|
||||
if (appSelectorModal) appSelectorModal.classList.remove('show');
|
||||
|
||||
document.getElementById('recipe-deploy-title').textContent = `Deploy ${recipe.name}`;
|
||||
|
||||
// Reset steps
|
||||
updateStepUI();
|
||||
renderStep1();
|
||||
|
||||
deployModal.classList.add('show');
|
||||
}
|
||||
|
||||
function updateStepUI() {
|
||||
// Step indicators
|
||||
document.querySelectorAll('#recipe-steps .recipe-step').forEach(el => {
|
||||
const step = parseInt(el.dataset.step);
|
||||
el.classList.toggle('active', step === currentStep);
|
||||
el.classList.toggle('completed', step < currentStep);
|
||||
});
|
||||
|
||||
// Step panels
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const panel = document.getElementById(`recipe-step-${i}`);
|
||||
if (panel) panel.style.display = i === currentStep ? '' : 'none';
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
prevBtn.style.display = currentStep > 1 && currentStep < 4 ? '' : 'none';
|
||||
if (currentStep === 4) {
|
||||
nextBtn.style.display = 'none';
|
||||
cancelBtn.textContent = 'Close';
|
||||
} else if (currentStep === 3) {
|
||||
nextBtn.textContent = '\uD83D\uDE80 Deploy';
|
||||
nextBtn.style.display = '';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
} else {
|
||||
nextBtn.textContent = 'Next';
|
||||
nextBtn.style.display = '';
|
||||
cancelBtn.textContent = 'Cancel';
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Component selection
|
||||
function renderStep1() {
|
||||
const list = document.getElementById('recipe-component-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
const components = currentRecipe.components || [];
|
||||
for (const comp of components) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);';
|
||||
|
||||
const isRequired = comp.required;
|
||||
const isInternal = comp.internal;
|
||||
|
||||
el.innerHTML = `
|
||||
<input type="checkbox" ${isRequired ? 'checked disabled' : 'checked'} data-component-id="${comp.id}"
|
||||
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(comp.role || comp.id)}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">
|
||||
${comp.templateRef ? escapeHtml(comp.templateRef) : 'Built-in'}
|
||||
${isRequired ? '<span style="color: var(--accent); margin-left: 6px;">Required</span>' : '<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${isInternal ? '<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>' : ''}
|
||||
</div>
|
||||
${comp.note ? `<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(comp.note)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Shared configuration
|
||||
function renderStep2() {
|
||||
const volumeSection = document.getElementById('recipe-volumes-section');
|
||||
const volumeList = document.getElementById('recipe-volume-list');
|
||||
|
||||
const sharedVolumes = currentRecipe.sharedVolumes;
|
||||
if (sharedVolumes && Object.keys(sharedVolumes).length > 0) {
|
||||
volumeSection.style.display = '';
|
||||
volumeList.innerHTML = '';
|
||||
|
||||
for (const [key, vol] of Object.entries(sharedVolumes)) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'display: grid; gap: 4px;';
|
||||
el.innerHTML = `
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(vol.label || key)}</label>
|
||||
<input type="text" data-volume-key="${key}" value="${escapeHtml(vol.defaultPath || '')}"
|
||||
placeholder="${escapeHtml(vol.defaultPath || '/path')}"
|
||||
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
|
||||
<div class="form-hint-sm">${escapeHtml(vol.description || '')}</div>
|
||||
`;
|
||||
volumeList.appendChild(el);
|
||||
}
|
||||
} else {
|
||||
volumeSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Review
|
||||
function renderStep3() {
|
||||
const content = document.getElementById('recipe-review-content');
|
||||
const selectedComponents = getSelectedComponents();
|
||||
|
||||
const volumeInputs = document.querySelectorAll('#recipe-volume-list input[data-volume-key]');
|
||||
const volumes = {};
|
||||
volumeInputs.forEach(input => {
|
||||
volumes[input.dataset.volumeKey] = input.value;
|
||||
});
|
||||
|
||||
const tz = document.getElementById('recipe-timezone').value || 'UTC';
|
||||
const ip = document.getElementById('recipe-ip').value || 'host.docker.internal';
|
||||
const tailscale = document.getElementById('recipe-tailscale').checked;
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(currentRecipe.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(currentRecipe.description || '')}</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong>Components (${selectedComponents.length}):</strong>
|
||||
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
||||
${selectedComponents.map(c => `<div style="padding: 4px 0; font-size: 0.85rem;">
|
||||
\u2022 <strong>${escapeHtml(c.role || c.id)}</strong> ${c.internal ? '<span style="color:var(--muted)">(internal)</span>' : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${Object.keys(volumes).length > 0 ? `<div style="margin-bottom: 12px;">
|
||||
<strong>Volumes:</strong>
|
||||
${Object.entries(volumes).map(([k, v]) => `<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${k}: ${escapeHtml(v)}</div>`).join('')}
|
||||
</div>` : ''}
|
||||
|
||||
<div style="font-size: 0.85rem; color: var(--muted);">
|
||||
Timezone: ${escapeHtml(tz)} • IP: ${escapeHtml(ip)} ${tailscale ? '• Tailscale only' : ''}
|
||||
</div>
|
||||
|
||||
${currentRecipe.network ? `<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(currentRecipe.network.name)}</code></div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function getSelectedComponents() {
|
||||
const checkboxes = document.querySelectorAll('#recipe-component-list input[data-component-id]');
|
||||
const selectedIds = new Set();
|
||||
checkboxes.forEach(cb => {
|
||||
if (cb.checked) selectedIds.add(cb.dataset.componentId);
|
||||
});
|
||||
|
||||
// Always include required components
|
||||
const components = currentRecipe.components || [];
|
||||
components.filter(c => c.required).forEach(c => selectedIds.add(c.id));
|
||||
|
||||
return components.filter(c => selectedIds.has(c.id));
|
||||
}
|
||||
|
||||
// Step 4: Deploy
|
||||
async function executeDeploy() {
|
||||
const progressList = document.getElementById('recipe-progress-list');
|
||||
const resultEl = document.getElementById('recipe-deploy-result');
|
||||
resultEl.style.display = 'none';
|
||||
progressList.innerHTML = '';
|
||||
|
||||
const selectedComponents = getSelectedComponents();
|
||||
|
||||
// Show progress items
|
||||
for (const comp of selectedComponents) {
|
||||
const el = document.createElement('div');
|
||||
el.id = `recipe-progress-${comp.id}`;
|
||||
el.style.cssText = 'display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;';
|
||||
el.innerHTML = `
|
||||
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
||||
<span style="flex:1; font-weight: 500;">${escapeHtml(comp.role || comp.id)}</span>
|
||||
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
||||
`;
|
||||
progressList.appendChild(el);
|
||||
}
|
||||
|
||||
// Collect config
|
||||
const volumeInputs = document.querySelectorAll('#recipe-volume-list input[data-volume-key]');
|
||||
const volumes = {};
|
||||
volumeInputs.forEach(input => {
|
||||
volumes[input.dataset.volumeKey] = input.value;
|
||||
});
|
||||
|
||||
const config = {
|
||||
selectedComponents: selectedComponents.map(c => c.id),
|
||||
sharedConfig: {
|
||||
ip: document.getElementById('recipe-ip').value || 'host.docker.internal',
|
||||
timezone: document.getElementById('recipe-timezone').value || 'UTC',
|
||||
tailscaleOnly: document.getElementById('recipe-tailscale').checked,
|
||||
volumes
|
||||
},
|
||||
componentOverrides: {}
|
||||
};
|
||||
|
||||
// Mark all as deploying
|
||||
for (const comp of selectedComponents) {
|
||||
updateProgressItem(comp.id, 'deploying', 'Deploying...');
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await secureFetch('/api/v1/recipes/deploy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipeId: currentRecipe.id, config })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
// Mark deployed components
|
||||
for (const deployed of (data.deployed || [])) {
|
||||
updateProgressItem(deployed.id, 'success', deployed.url ? `Running \u2192 ${deployed.url}` : 'Running');
|
||||
}
|
||||
// Mark errors
|
||||
for (const err of (data.errors || [])) {
|
||||
updateProgressItem(err.componentId, 'error', err.error);
|
||||
}
|
||||
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML = `
|
||||
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
|
||||
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(data.message || 'Deployed!')}</div>
|
||||
${data.setupInstructions ? `<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
<strong>Setup tips:</strong>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0;">${data.setupInstructions.map(s => `<li>${escapeHtml(s)}</li>`).join('')}</ul>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
showNotification(`${currentRecipe.name} recipe deployed successfully!`, 'success', 5000);
|
||||
|
||||
// Refresh dashboard
|
||||
if (window.loadServices) window.loadServices();
|
||||
} else {
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML = `<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(data.error || 'Unknown error')}
|
||||
</div>`;
|
||||
showNotification(`Recipe deployment failed: ${data.error}`, 'error', 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML = `<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(e.message)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgressItem(componentId, status, text) {
|
||||
const el = document.getElementById(`recipe-progress-${componentId}`);
|
||||
if (!el) return;
|
||||
const icon = el.querySelector('.recipe-progress-icon');
|
||||
const statusEl = el.querySelector('.recipe-progress-status');
|
||||
|
||||
if (status === 'deploying') {
|
||||
icon.textContent = '\u23F3';
|
||||
statusEl.style.color = 'var(--accent)';
|
||||
} else if (status === 'success') {
|
||||
icon.textContent = '\u2705';
|
||||
statusEl.style.color = 'var(--ok-fg)';
|
||||
} else if (status === 'error') {
|
||||
icon.textContent = '\u274C';
|
||||
statusEl.style.color = 'var(--bad-fg)';
|
||||
}
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
// === STEP NAVIGATION ===
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (currentStep === 3) {
|
||||
currentStep = 4;
|
||||
updateStepUI();
|
||||
executeDeploy();
|
||||
return;
|
||||
}
|
||||
if (currentStep < 3) {
|
||||
currentStep++;
|
||||
updateStepUI();
|
||||
if (currentStep === 2) renderStep2();
|
||||
if (currentStep === 3) renderStep3();
|
||||
}
|
||||
});
|
||||
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (currentStep > 1 && currentStep < 4) {
|
||||
currentStep--;
|
||||
updateStepUI();
|
||||
}
|
||||
});
|
||||
|
||||
// === RECIPE CARD GROUPING ON DASHBOARD ===
|
||||
// After dashboard loads, group cards that share a recipeId
|
||||
window.groupRecipeCards = function() {
|
||||
const cards = document.querySelectorAll('.service-card[data-recipe-id]');
|
||||
if (cards.length === 0) return;
|
||||
|
||||
const groups = {};
|
||||
cards.forEach(card => {
|
||||
const recipeId = card.dataset.recipeId;
|
||||
if (!groups[recipeId]) groups[recipeId] = [];
|
||||
groups[recipeId].push(card);
|
||||
});
|
||||
|
||||
for (const [recipeId, groupCards] of Object.entries(groups)) {
|
||||
if (groupCards.length < 2) continue;
|
||||
|
||||
// Apply subtle visual grouping
|
||||
groupCards.forEach((card, i) => {
|
||||
card.style.borderLeft = '3px solid rgba(142,68,173,0.5)';
|
||||
if (i === 0) {
|
||||
// Add a recipe label to the first card
|
||||
let label = card.querySelector('.recipe-group-label');
|
||||
if (!label) {
|
||||
label = document.createElement('div');
|
||||
label.className = 'recipe-group-label';
|
||||
label.style.cssText = 'position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;';
|
||||
label.textContent = recipeId.replace(/-/g, ' ');
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(label);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// === RECIPE MANAGEMENT ACTIONS ===
|
||||
window.manageRecipe = async function(recipeId, action) {
|
||||
const endpoint = `/api/v1/recipes/${recipeId}/${action}`;
|
||||
const method = action === 'remove' ? 'DELETE' : 'POST';
|
||||
const url = action === 'remove' ? `/api/v1/recipes/${recipeId}` : endpoint;
|
||||
|
||||
if (action === 'remove' && !confirm(`Remove the entire ${recipeId} recipe? This will delete all containers and configuration.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await secureFetch(url, { method });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showNotification(`Recipe ${action}: ${data.results?.filter(r => r.status !== 'failed').length || 0} components processed`, 'success', 4000);
|
||||
if (window.loadServices) window.loadServices();
|
||||
} else {
|
||||
showNotification(`Recipe ${action} failed: ${data.error}`, 'error', 5000);
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification(`Network error: ${e.message}`, 'error', 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// === INJECT CSS ===
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.recipe-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid var(--border);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.recipe-step span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.recipe-step.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.recipe-step.active span {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.recipe-step.completed {
|
||||
color: var(--ok-fg);
|
||||
border-bottom-color: var(--ok-fg);
|
||||
}
|
||||
.recipe-step.completed span {
|
||||
background: var(--ok-fg);
|
||||
color: #fff;
|
||||
}
|
||||
.recipe-step-panel {
|
||||
min-height: 180px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// === INIT ===
|
||||
// Pre-fetch premium status on load
|
||||
checkRecipePremium();
|
||||
})();
|
||||
328
status/js/resource-monitor.js
Normal file
328
status/js/resource-monitor.js
Normal file
@@ -0,0 +1,328 @@
|
||||
// ========== RESOURCE MONITOR (Enhanced) ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('stats-modal', `<div id="stats-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>📊 Resource Monitor</h3>
|
||||
<p class="modal-subtitle">
|
||||
Real-time and historical CPU, memory, network, and disk usage for containers.
|
||||
</p>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" data-panel="stats-live">Live Stats</button>
|
||||
<button class="panel-tab" data-panel="stats-aggregated">24h Summary</button>
|
||||
<button class="panel-tab" data-panel="stats-alerts">Alerts</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Live Stats -->
|
||||
<div id="stats-live" class="panel-section active">
|
||||
<div id="stats-container" class="scroll-container">
|
||||
<div style="text-align: center; padding: 40px; color: var(--muted);">
|
||||
<span class="brand-spinner"></span> Loading container stats...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 24h Aggregated Summary -->
|
||||
<div id="stats-aggregated" class="panel-section">
|
||||
<div id="stats-aggregated-container" class="scroll-container">
|
||||
<div class="panel-empty">
|
||||
<span class="empty-icon">📈</span>
|
||||
Loading 24-hour aggregated metrics...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Alert Configuration -->
|
||||
<div id="stats-alerts" class="panel-section">
|
||||
<div id="stats-alerts-container" class="scroll-container">
|
||||
<div class="panel-empty">
|
||||
<span class="empty-icon">🔔</span>
|
||||
Loading alert configurations...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh toggle (bottom bar) -->
|
||||
<div class="panel-bottom-bar">
|
||||
<label class="checkbox-label" style="font-size: 0.85rem;">
|
||||
<input type="checkbox" id="stats-auto-refresh" checked />
|
||||
Auto-refresh every 5s
|
||||
</label>
|
||||
<button id="stats-refresh-btn" class="btn-sm">🔄 Refresh Now</button>
|
||||
<span id="stats-last-update" class="text-auto-right"></span>
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="stats-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('stats-modal');
|
||||
const openBtn = document.getElementById('container-stats-btn');
|
||||
const cancelBtn = document.getElementById('stats-cancel');
|
||||
const refreshBtn = document.getElementById('stats-refresh-btn');
|
||||
const autoRefreshCheckbox = document.getElementById('stats-auto-refresh');
|
||||
const container = document.getElementById('stats-container');
|
||||
const aggregatedContainer = document.getElementById('stats-aggregated-container');
|
||||
const alertsContainer = document.getElementById('stats-alerts-container');
|
||||
const lastUpdateSpan = document.getElementById('stats-last-update');
|
||||
|
||||
let refreshInterval = null;
|
||||
let cachedMonitoringData = null;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0 || !bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function getCpuColor(percent) {
|
||||
if (percent < 30) return '#2ecc71';
|
||||
if (percent < 70) return '#f39c12';
|
||||
return '#e74c3c';
|
||||
}
|
||||
|
||||
function getMemColor(percent) {
|
||||
if (percent < 50) return '#2ecc71';
|
||||
if (percent < 80) return '#f39c12';
|
||||
return '#e74c3c';
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
// Try new monitoring API first, fall back to old
|
||||
let stats = null;
|
||||
let isNewApi = false;
|
||||
try {
|
||||
const res = await fetch('/api/v1/monitoring/stats');
|
||||
const data = await res.json();
|
||||
if (data.success && data.stats) { stats = data.stats; isNewApi = true; cachedMonitoringData = data.stats; }
|
||||
} catch (_) {}
|
||||
|
||||
if (!isNewApi) {
|
||||
const response = await fetch('/api/v1/stats/containers');
|
||||
const data = await response.json();
|
||||
if (data.success && data.stats) {
|
||||
// Convert array format to object format
|
||||
stats = {};
|
||||
for (const s of data.stats) {
|
||||
stats[s.name] = { name: s.name, current: { cpu: s.cpu, memory: { percent: s.memory.percent, usage: s.memory.used, limit: s.memory.limit, usageMB: Math.round(s.memory.used / 1048576), limitMB: Math.round(s.memory.limit / 1048576) }, network: { rxBytes: s.network.rx, txBytes: s.network.tx, rxMB: (s.network.rx / 1048576).toFixed(1), txMB: (s.network.tx / 1048576).toFixed(1) }, disk: { readMB: 0, writeMB: 0 } }, status: s.status };
|
||||
}
|
||||
cachedMonitoringData = stats;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stats || Object.keys(stats).length === 0) {
|
||||
container.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||||
|
||||
for (const [id, info] of Object.entries(stats)) {
|
||||
const cur = info.current || info;
|
||||
const cpu = cur.cpu?.percent || 0;
|
||||
const mem = cur.memory?.percent || 0;
|
||||
const cpuColor = getCpuColor(cpu);
|
||||
const memColor = getMemColor(mem);
|
||||
const memUsed = cur.memory?.usage || cur.memory?.used || 0;
|
||||
const memLimit = cur.memory?.limit || 0;
|
||||
const netRx = cur.network?.rxBytes || cur.network?.rx || 0;
|
||||
const netTx = cur.network?.txBytes || cur.network?.tx || 0;
|
||||
const agg = info.aggregated;
|
||||
|
||||
html += `
|
||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${info.name || id}</span>
|
||||
${agg ? `<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${agg.cpu?.avg?.toFixed(0) || 0}% cpu</span>` : ''}
|
||||
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${info.status || 'running'}</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(cpu, 100)}%; background: ${cpuColor}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${cpuColor}; min-width: 45px; text-align: right;">${cpu.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(mem, 100)}%; background: ${memColor}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${memColor}; min-width: 45px; text-align: right;">${mem.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${formatBytes(memUsed)} / ${formatBytes(memLimit)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
||||
<div style="font-size: 0.8rem;">
|
||||
<span style="color: #3498db;">↓ ${formatBytes(netRx)}</span>
|
||||
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
||||
<span style="color: #e74c3c;">↑ ${formatBytes(netTx)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
lastUpdateSpan.textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--bad-fg);">❌ Failed to load stats: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// === 24h Aggregated Tab ===
|
||||
async function loadAggregated() {
|
||||
if (!aggregatedContainer) return;
|
||||
const data = cachedMonitoringData;
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
aggregatedContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📈</span>No monitoring data available. Open the Live Stats tab first.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||||
for (const [id, info] of Object.entries(data)) {
|
||||
const agg = info.aggregated;
|
||||
if (!agg) continue;
|
||||
html += `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${info.name || id}</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
||||
<div class="stat-mini-card"><span class="stat-val">${agg.cpu?.avg?.toFixed(1) || 0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
||||
<div class="stat-mini-card"><span class="stat-val">${agg.cpu?.max?.toFixed(1) || 0}%</span><span class="stat-lbl">Max CPU</span></div>
|
||||
<div class="stat-mini-card"><span class="stat-val">${agg.memory?.avg?.toFixed(1) || 0}%</span><span class="stat-lbl">Avg Mem</span></div>
|
||||
<div class="stat-mini-card"><span class="stat-val">${agg.memory?.max?.toFixed(1) || 0}%</span><span class="stat-lbl">Max Mem</span></div>
|
||||
</div>
|
||||
${agg.dataPoints ? `<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${agg.dataPoints} data points over ${agg.timeRange || 24}h</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
aggregatedContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
// === Alerts Tab ===
|
||||
async function loadAlerts() {
|
||||
if (!alertsContainer) return;
|
||||
alertsContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';
|
||||
const data = cachedMonitoringData;
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
alertsContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🔔</span>No containers found. Open the Live Stats tab first.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 12px;">';
|
||||
for (const [id, info] of Object.entries(data)) {
|
||||
const alertCfg = info.alertConfig || {};
|
||||
html += `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${info.name || id}</span>
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
||||
<input type="checkbox" class="alert-enabled" data-container="${id}" ${alertCfg.enabled ? 'checked' : ''} /> Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
|
||||
<input type="number" class="alert-cpu" data-container="${id}" value="${alertCfg.cpuThreshold || 80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
|
||||
<input type="number" class="alert-mem" data-container="${id}" value="${alertCfg.memoryThreshold || 85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
|
||||
<input type="number" class="alert-cooldown" data-container="${id}" value="${alertCfg.cooldownMinutes || 15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
||||
<input type="checkbox" class="alert-autorestart" data-container="${id}" ${alertCfg.autoRestart ? 'checked' : ''} /> Auto-restart on breach
|
||||
</label>
|
||||
<span style="flex: 1;"></span>
|
||||
<button class="alert-save-btn" data-container="${id}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
alertsContainer.innerHTML = html;
|
||||
|
||||
// Wire up save buttons
|
||||
alertsContainer.querySelectorAll('.alert-save-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cId = btn.dataset.container;
|
||||
const enabled = alertsContainer.querySelector(`.alert-enabled[data-container="${cId}"]`)?.checked || false;
|
||||
const cpuThreshold = parseInt(alertsContainer.querySelector(`.alert-cpu[data-container="${cId}"]`)?.value) || 80;
|
||||
const memoryThreshold = parseInt(alertsContainer.querySelector(`.alert-mem[data-container="${cId}"]`)?.value) || 85;
|
||||
const cooldownMinutes = parseInt(alertsContainer.querySelector(`.alert-cooldown[data-container="${cId}"]`)?.value) || 15;
|
||||
const autoRestart = alertsContainer.querySelector(`.alert-autorestart[data-container="${cId}"]`)?.checked || false;
|
||||
try {
|
||||
const res = await secureFetch(`/api/v1/monitoring/alerts/${cId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled, cpuThreshold, memoryThreshold, cooldownMinutes, autoRestart })
|
||||
});
|
||||
const data = await res.json();
|
||||
btn.textContent = data.success ? '✅ Saved' : '⚠️ Failed';
|
||||
setTimeout(() => { btn.textContent = 'Save'; }, 2000);
|
||||
} catch (e) {
|
||||
btn.textContent = '❌ Error';
|
||||
setTimeout(() => { btn.textContent = 'Save'; }, 2000);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (autoRefreshCheckbox?.checked) {
|
||||
refreshInterval = setInterval(loadStats, DC.POLL.STATS);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Open modal
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
loadStats();
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
// Close modal
|
||||
cancelBtn?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
stopAutoRefresh();
|
||||
});
|
||||
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.remove('show');
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener('click', loadStats);
|
||||
|
||||
autoRefreshCheckbox?.addEventListener('change', () => {
|
||||
if (autoRefreshCheckbox.checked) startAutoRefresh();
|
||||
else stopAutoRefresh();
|
||||
});
|
||||
|
||||
// Lazy-load tabs
|
||||
document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener('click', loadAggregated);
|
||||
document.querySelector('[data-panel="stats-alerts"]')?.addEventListener('click', loadAlerts);
|
||||
})();
|
||||
284
status/js/service-credentials.js
Normal file
284
status/js/service-credentials.js
Normal file
@@ -0,0 +1,284 @@
|
||||
// ===== SERVICE CREDENTIALS =====
|
||||
(function() {
|
||||
injectModal('folder-browser-modal', `<div id="folder-browser-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
|
||||
<h3>📂 Browse for Media Folders</h3>
|
||||
|
||||
<div id="folder-browser-path" style="padding: 10px; background: var(--card-bg); border-radius: 6px; margin-bottom: 12px; font-family: monospace; font-size: 0.9rem; word-break: break-all;">
|
||||
/
|
||||
</div>
|
||||
|
||||
<div id="folder-browser-list" style="max-height: 400px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px;">
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div id="folder-browser-selected" style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--success) 10%, transparent); border: 1px solid var(--success); border-radius: 6px; display: none;">
|
||||
<div style="font-size: 0.85rem; color: var(--success); margin-bottom: 6px;">Selected folders:</div>
|
||||
<div id="folder-browser-selected-list" style="display: flex; flex-wrap: wrap; gap: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 16px; display: flex; gap: 8px; justify-content: space-between;">
|
||||
<button id="folder-browser-select-current" class="btn-accent">
|
||||
+ Add Current Folder
|
||||
</button>
|
||||
<div class="flex-row-gap">
|
||||
<button id="folder-browser-cancel">Cancel</button>
|
||||
<button id="folder-browser-done" style="background: color-mix(in srgb, var(--success) 20%, transparent); border-color: var(--success); color: var(--success);">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
injectModal('service-creds-modal', `<div id="service-creds-modal">
|
||||
<div class="service-creds-content">
|
||||
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
||||
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||
<span id="svc-creds-dot" class="status-dot"></span>
|
||||
<span id="svc-creds-status" class="text-muted-sm">No credentials stored</span>
|
||||
</div>
|
||||
|
||||
<!-- Seedhost credentials (shown for external services) -->
|
||||
<div id="svc-creds-seedhost" style="display: none; margin-bottom: 14px;">
|
||||
<label class="label-bold">Seedhost Login</label>
|
||||
<p class="hint-micro">Username shared across all services. Password is per-service.</p>
|
||||
<input type="text" id="svc-seedhost-user" placeholder="Username (shared)" autocomplete="username"
|
||||
style="width: 100%; padding: 8px 10px; margin-bottom: 6px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;" />
|
||||
<input type="password" id="svc-seedhost-pass" placeholder="Password" autocomplete="current-password"
|
||||
class="input-creds" />
|
||||
</div>
|
||||
|
||||
<!-- API Key (shown for arr services or services with API key support) -->
|
||||
<div id="svc-creds-apikey" style="display: none; margin-bottom: 14px;">
|
||||
<label class="label-bold">API Key</label>
|
||||
<p class="hint-micro">Bypasses the app's own login screen</p>
|
||||
<input type="text" id="svc-apikey-input" placeholder="API key"
|
||||
style="width: 100%; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; font-family: monospace; box-sizing: border-box;" />
|
||||
</div>
|
||||
|
||||
<!-- Per-service Basic Auth (shown for non-external services) -->
|
||||
<div id="svc-creds-basic" style="display: none; margin-bottom: 14px;">
|
||||
<label class="label-bold">Service Login</label>
|
||||
<input type="text" id="svc-basic-user" placeholder="Username" autocomplete="username"
|
||||
style="width: 100%; padding: 8px 10px; margin-top: 6px; margin-bottom: 6px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;" />
|
||||
<input type="password" id="svc-basic-pass" placeholder="Password" autocomplete="current-password"
|
||||
class="input-creds" />
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
||||
<button id="svc-creds-save" style="flex: 1; padding: 9px; background: var(--accent, #8FD6FF); color: #0b0f1a; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
||||
Save
|
||||
</button>
|
||||
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
||||
Clear
|
||||
</button>
|
||||
<button id="svc-creds-close" style="padding: 9px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.85rem;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('service-creds-modal');
|
||||
let currentService = null;
|
||||
const arrServices = ['sonarr', 'radarr', 'prowlarr', 'overseerr'];
|
||||
|
||||
window.openServiceCredsModal = async function(service) {
|
||||
currentService = service;
|
||||
const title = document.getElementById('svc-creds-title');
|
||||
const desc = document.getElementById('svc-creds-desc');
|
||||
const seedhostSection = document.getElementById('svc-creds-seedhost');
|
||||
const apikeySection = document.getElementById('svc-creds-apikey');
|
||||
const basicSection = document.getElementById('svc-creds-basic');
|
||||
|
||||
title.textContent = service.name + ' Credentials';
|
||||
// Determine which sections to show
|
||||
const isExt = !!service.isExternal;
|
||||
const isArr = arrServices.includes(service.id) || arrServices.includes(service.appTemplate);
|
||||
|
||||
seedhostSection.style.display = isExt ? '' : 'none';
|
||||
apikeySection.style.display = isArr ? '' : 'none';
|
||||
basicSection.style.display = !isExt ? '' : 'none';
|
||||
|
||||
if (isExt) {
|
||||
desc.textContent = 'Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.';
|
||||
// Update password placeholder with service name
|
||||
document.getElementById('svc-seedhost-pass').placeholder = `Password for ${service.name}`;
|
||||
} else if (isArr) {
|
||||
desc.textContent = 'API key bypasses the app login screen automatically.';
|
||||
} else {
|
||||
desc.textContent = 'Credentials are injected automatically when accessing this service.';
|
||||
}
|
||||
|
||||
// Load existing credentials
|
||||
await loadServiceCreds(service);
|
||||
modal.classList.add('show');
|
||||
};
|
||||
|
||||
async function loadServiceCreds(service) {
|
||||
const dot = document.getElementById('svc-creds-dot');
|
||||
const status = document.getElementById('svc-creds-status');
|
||||
const clearBtn = document.getElementById('svc-creds-clear');
|
||||
let hasCreds = false;
|
||||
|
||||
// Load seedhost creds (shared username + per-service password)
|
||||
if (service.isExternal) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/seedhost-creds?serviceId=${service.id}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('svc-seedhost-user').value = data.username || '';
|
||||
if (data.hasCredentials) hasCreds = true;
|
||||
} else {
|
||||
document.getElementById('svc-seedhost-user').value = '';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
document.getElementById('svc-seedhost-pass').value = '';
|
||||
}
|
||||
|
||||
// Load per-service creds
|
||||
try {
|
||||
const res = await fetch(`/api/v1/services/${service.id}/credentials`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (data.hasApiKey) {
|
||||
document.getElementById('svc-apikey-input').value = '••••••••';
|
||||
hasCreds = true;
|
||||
} else {
|
||||
document.getElementById('svc-apikey-input').value = '';
|
||||
}
|
||||
if (data.hasBasicAuth && !service.isExternal) {
|
||||
document.getElementById('svc-basic-user').value = data.username || '';
|
||||
hasCreds = true;
|
||||
} else {
|
||||
document.getElementById('svc-basic-user').value = '';
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
if (document.getElementById('svc-basic-pass')) document.getElementById('svc-basic-pass').value = '';
|
||||
|
||||
if (hasCreds) {
|
||||
dot.style.background = 'var(--ok-fg, #74dfc4)';
|
||||
status.style.color = 'var(--ok-fg, #74dfc4)';
|
||||
status.textContent = 'Credentials stored';
|
||||
clearBtn.style.display = '';
|
||||
// Update the card button
|
||||
const btn = document.getElementById(`creds-btn-${service.id}`);
|
||||
if (btn) btn.classList.add('has-creds');
|
||||
} else {
|
||||
dot.style.background = 'var(--muted)';
|
||||
status.style.color = 'var(--muted)';
|
||||
status.textContent = 'No credentials stored';
|
||||
clearBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Save button
|
||||
document.getElementById('svc-creds-save')?.addEventListener('click', async () => {
|
||||
if (!currentService) return;
|
||||
const saveBtn = document.getElementById('svc-creds-save');
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
// Save seedhost creds (shared username + per-service password)
|
||||
if (currentService.isExternal) {
|
||||
const user = document.getElementById('svc-seedhost-user').value.trim();
|
||||
const pass = document.getElementById('svc-seedhost-pass').value;
|
||||
if (user) {
|
||||
await secureFetch('/api/v1/seedhost-creds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass || undefined, serviceId: currentService.id })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Save API key
|
||||
const apiKeyInput = document.getElementById('svc-apikey-input');
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
if (apiKey && apiKey !== '••••••••') {
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey })
|
||||
});
|
||||
}
|
||||
|
||||
// Save per-service basic auth
|
||||
if (!currentService.isExternal) {
|
||||
const user = document.getElementById('svc-basic-user').value.trim();
|
||||
const pass = document.getElementById('svc-basic-pass').value;
|
||||
if (user && pass) {
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: user, password: pass })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await loadServiceCreds(currentService);
|
||||
} catch (e) {
|
||||
console.error('Failed to save credentials:', e);
|
||||
}
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Clear button
|
||||
document.getElementById('svc-creds-clear')?.addEventListener('click', async () => {
|
||||
if (!currentService) return;
|
||||
if (!confirm(`Remove stored credentials for ${currentService.name}?`)) return;
|
||||
try {
|
||||
if (currentService.isExternal) {
|
||||
await secureFetch(`/api/v1/seedhost-creds?serviceId=${currentService.id}`, { method: 'DELETE' });
|
||||
}
|
||||
await secureFetch(`/api/v1/services/${currentService.id}/credentials`, { method: 'DELETE' });
|
||||
const btn = document.getElementById(`creds-btn-${currentService.id}`);
|
||||
if (btn) btn.classList.remove('has-creds');
|
||||
await loadServiceCreds(currentService);
|
||||
} catch (e) {
|
||||
console.error('Failed to clear credentials:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Close button / backdrop
|
||||
document.getElementById('svc-creds-close')?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
});
|
||||
modal?.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.remove('show');
|
||||
currentService = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Check credential status for all services on page load (update key button highlights)
|
||||
window.refreshCredsButtons = async function() {
|
||||
try {
|
||||
for (const app of (window.APPS || [])) {
|
||||
if (!app.isExternal && !app.appTemplate && !app.url) continue;
|
||||
let hasCreds = false;
|
||||
if (app.isExternal) {
|
||||
try {
|
||||
const seedRes = await fetch(`/api/v1/seedhost-creds?serviceId=${app.id}`);
|
||||
const seedData = await seedRes.json();
|
||||
if (seedData.success && seedData.hasCredentials) hasCreds = true;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/v1/services/${app.id}/credentials`);
|
||||
const d = await r.json();
|
||||
if (d.success && (d.hasApiKey || d.hasBasicAuth)) hasCreds = true;
|
||||
} catch (e) { /* ignore */ }
|
||||
const btn = document.getElementById(`creds-btn-${app.id}`);
|
||||
if (btn) btn.classList.toggle('has-creds', hasCreds);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
})();
|
||||
414
status/js/setup-wizard.js
Normal file
414
status/js/setup-wizard.js
Normal file
@@ -0,0 +1,414 @@
|
||||
// Shared timezone utility — used by setup wizard and settings modal
|
||||
window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
||||
const timezones = Intl.supportedValuesOf('timeZone');
|
||||
const detected = selectedTz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
selectEl.innerHTML = '';
|
||||
for (const tz of timezones) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tz;
|
||||
opt.textContent = tz.replace(/_/g, ' ');
|
||||
if (tz === detected) opt.selected = true;
|
||||
selectEl.appendChild(opt);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup Wizard System - Server-side config storage
|
||||
(function () {
|
||||
let currentConfigType = 'homelab';
|
||||
let serverConfig = null;
|
||||
|
||||
// Check server for existing config on page load
|
||||
async function checkServerConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/config');
|
||||
if (response.ok) {
|
||||
serverConfig = await response.json();
|
||||
if (serverConfig && serverConfig.setupComplete) {
|
||||
// Config exists on server - don't show wizard
|
||||
document.getElementById('setup-wizard').style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch server config, checking localStorage fallback:', error.message);
|
||||
}
|
||||
|
||||
// Fallback: check localStorage for backwards compatibility
|
||||
const localSetup = safeGet('dashcaddy-setup');
|
||||
if (localSetup) {
|
||||
document.getElementById('setup-wizard').style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
|
||||
// No config found - show wizard
|
||||
document.getElementById('setup-wizard').style.display = 'flex';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
checkServerConfig();
|
||||
|
||||
// Populate timezone dropdown with auto-detection
|
||||
const setupTzSelect = document.getElementById('setup-timezone');
|
||||
if (setupTzSelect) window.populateTimezoneSelect(setupTzSelect);
|
||||
|
||||
// Step navigation
|
||||
function showStep(stepId) {
|
||||
document.querySelectorAll('.setup-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
const targetStep = document.getElementById(stepId);
|
||||
if (targetStep) {
|
||||
targetStep.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show summary
|
||||
function showSummary() {
|
||||
const summaryContent = document.getElementById('setup-summary-content');
|
||||
if (!summaryContent) return;
|
||||
|
||||
let html = '<div style="display: grid; gap: 20px;">';
|
||||
|
||||
if (currentConfigType === 'homelab') {
|
||||
const tld = document.getElementById('setup-tld')?.value?.trim() || '.home';
|
||||
const caName = document.getElementById('setup-ca-name')?.value?.trim() || '';
|
||||
const dnsIP = document.getElementById('setup-dns-ip')?.value?.trim() || '';
|
||||
const dnsPort = document.getElementById('setup-dns-port')?.value?.trim() || DC.DEFAULTS.DNS_PORT;
|
||||
|
||||
html += `
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>TLD:</strong> ${tld}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${caName}</div>
|
||||
<div><strong>DNS Server:</strong> ${dnsIP}:${dnsPort}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${tld}, https://nextcloud${tld}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (currentConfigType === 'simple') {
|
||||
const ip = document.getElementById('setup-simple-ip')?.value?.trim() || 'localhost';
|
||||
|
||||
html += `
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Access Method:</strong> IP:Port only</div>
|
||||
<div><strong>Default IP:</strong> ${ip}</div>
|
||||
<div><strong>SSL:</strong> None (HTTP only)</div>
|
||||
<div><strong>Example URLs:</strong> http://${ip}:8080, http://${ip}:3000</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (currentConfigType === 'public') {
|
||||
const domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||||
const email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||||
|
||||
html += `
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Domain:</strong> ${domain}</div>
|
||||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||||
<div><strong>Email:</strong> ${email}</div>
|
||||
<div><strong>Example URLs:</strong> https://app.${domain}, https://cloud.${domain}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Timezone (universal across all config types)
|
||||
const tz = document.getElementById('setup-timezone')?.value || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
html += `
|
||||
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${tz.replace(/_/g, ' ')}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
html += '</div>';
|
||||
summaryContent.innerHTML = html;
|
||||
showStep('setup-step-summary');
|
||||
}
|
||||
|
||||
// Save config to server
|
||||
async function saveConfigToServer(config) {
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await response.json();
|
||||
return true;
|
||||
} else {
|
||||
console.error('Failed to save config to server:', response.status);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving config to server:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Finish setup handler
|
||||
async function finishSetup() {
|
||||
const config = {
|
||||
setupComplete: true,
|
||||
configurationType: currentConfigType,
|
||||
timestamp: new Date().toISOString(),
|
||||
timezone: document.getElementById('setup-timezone')?.value || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||
};
|
||||
|
||||
if (currentConfigType === 'homelab') {
|
||||
config.tld = document.getElementById('setup-tld')?.value?.trim() || '.home';
|
||||
config.caName = document.getElementById('setup-ca-name')?.value?.trim() || '';
|
||||
config.dns = {
|
||||
provider: 'technitium',
|
||||
ip: document.getElementById('setup-dns-ip')?.value?.trim() || '',
|
||||
port: document.getElementById('setup-dns-port')?.value?.trim() || DC.DEFAULTS.DNS_PORT,
|
||||
token: document.getElementById('setup-dns-token')?.value?.trim() || ''
|
||||
};
|
||||
config.defaults = {
|
||||
dnsType: 'private',
|
||||
sslType: 'internal',
|
||||
targetIP: 'localhost'
|
||||
};
|
||||
} else if (currentConfigType === 'simple') {
|
||||
config.defaultIP = document.getElementById('setup-simple-ip')?.value?.trim() || 'localhost';
|
||||
config.defaults = {
|
||||
dnsType: 'none',
|
||||
sslType: 'none',
|
||||
targetIP: config.defaultIP
|
||||
};
|
||||
} else if (currentConfigType === 'public') {
|
||||
config.domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||||
config.email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||||
config.defaults = {
|
||||
dnsType: 'public',
|
||||
sslType: 'letsencrypt',
|
||||
targetIP: 'localhost'
|
||||
};
|
||||
}
|
||||
|
||||
// Save to server (primary) and localStorage (fallback)
|
||||
const savedToServer = await saveConfigToServer(config);
|
||||
safeSet('dashcaddy-config', JSON.stringify(config));
|
||||
safeSet('dashcaddy-setup', 'completed');
|
||||
|
||||
// Hide wizard
|
||||
document.getElementById('setup-wizard').style.display = 'none';
|
||||
|
||||
// Show success notification
|
||||
const configName = currentConfigType === 'homelab' ? 'Professional Home Lab' :
|
||||
currentConfigType === 'simple' ? 'Simple Setup' : 'Public Server';
|
||||
const saveLocation = savedToServer ? 'server (shared across all devices)' : 'locally (this browser only)';
|
||||
|
||||
showNotification(`Setup Complete! Configured for: ${configName}. Settings saved to: ${saveLocation}`, 'success', 5000);
|
||||
|
||||
// Reload page to apply configuration
|
||||
setTimeout(() => location.reload(), 500);
|
||||
}
|
||||
|
||||
// ===== Event Handlers using direct onclick for reliability =====
|
||||
|
||||
// Step 1: Continue button
|
||||
const step1Next = document.getElementById('setup-step-1-next');
|
||||
if (step1Next) {
|
||||
step1Next.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
const selected = document.querySelector('input[name="config-type"]:checked');
|
||||
if (selected) {
|
||||
currentConfigType = selected.value;
|
||||
}
|
||||
if (currentConfigType === 'homelab') {
|
||||
showStep('setup-step-homelab');
|
||||
} else if (currentConfigType === 'simple') {
|
||||
showStep('setup-step-simple');
|
||||
} else if (currentConfigType === 'public') {
|
||||
showStep('setup-step-public');
|
||||
} else {
|
||||
showStep('setup-step-homelab');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Skip setup button
|
||||
const skipBtn = document.getElementById('setup-skip');
|
||||
if (skipBtn) {
|
||||
skipBtn.onclick = async function(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Skip setup? You can run it later from Settings.')) {
|
||||
// Save skip status to server
|
||||
await saveConfigToServer({ setupComplete: true, skipped: true, timestamp: new Date().toISOString() });
|
||||
safeSet('dashcaddy-setup', 'skipped');
|
||||
document.getElementById('setup-wizard').style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Home Lab: TLD Preview
|
||||
const tldInput = document.getElementById('setup-tld');
|
||||
if (tldInput) {
|
||||
tldInput.oninput = function(e) {
|
||||
const tld = e.target.value || '.home';
|
||||
const preview1 = document.getElementById('tld-preview');
|
||||
const preview2 = document.getElementById('tld-preview-2');
|
||||
if (preview1) preview1.textContent = tld;
|
||||
if (preview2) preview2.textContent = tld;
|
||||
};
|
||||
}
|
||||
|
||||
// Home Lab navigation
|
||||
const homelabBack = document.getElementById('setup-homelab-back');
|
||||
if (homelabBack) {
|
||||
homelabBack.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-1');
|
||||
};
|
||||
}
|
||||
|
||||
const homelabNext = document.getElementById('setup-homelab-next');
|
||||
if (homelabNext) {
|
||||
homelabNext.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
const tld = document.getElementById('setup-tld')?.value?.trim() || '';
|
||||
const caName = document.getElementById('setup-ca-name')?.value?.trim() || '';
|
||||
const dnsIP = document.getElementById('setup-dns-ip')?.value?.trim() || '';
|
||||
|
||||
if (!tld || !tld.startsWith('.')) {
|
||||
showNotification('Please enter a valid TLD starting with a dot (e.g., .home)', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!caName) {
|
||||
showNotification('Please enter a Certificate Authority name', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!dnsIP) {
|
||||
showNotification('Please enter your DNS server IP address', 'warning');
|
||||
return;
|
||||
}
|
||||
showSummary();
|
||||
};
|
||||
}
|
||||
|
||||
// Simple navigation
|
||||
const simpleBack = document.getElementById('setup-simple-back');
|
||||
if (simpleBack) {
|
||||
simpleBack.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-1');
|
||||
};
|
||||
}
|
||||
|
||||
const simpleNext = document.getElementById('setup-simple-next');
|
||||
if (simpleNext) {
|
||||
simpleNext.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showSummary();
|
||||
};
|
||||
}
|
||||
|
||||
// Public navigation
|
||||
const publicBack = document.getElementById('setup-public-back');
|
||||
if (publicBack) {
|
||||
publicBack.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
showStep('setup-step-1');
|
||||
};
|
||||
}
|
||||
|
||||
const publicNext = document.getElementById('setup-public-next');
|
||||
if (publicNext) {
|
||||
publicNext.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
const domain = document.getElementById('setup-public-domain')?.value?.trim() || '';
|
||||
const email = document.getElementById('setup-public-email')?.value?.trim() || '';
|
||||
|
||||
if (!domain) {
|
||||
showNotification('Please enter your domain name', 'warning');
|
||||
return;
|
||||
}
|
||||
if (!email || !email.includes('@')) {
|
||||
showNotification('Please enter a valid email address', 'warning');
|
||||
return;
|
||||
}
|
||||
showSummary();
|
||||
};
|
||||
}
|
||||
|
||||
// Summary navigation
|
||||
const summaryBack = document.getElementById('setup-summary-back');
|
||||
if (summaryBack) {
|
||||
summaryBack.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
if (currentConfigType === 'homelab') {
|
||||
showStep('setup-step-homelab');
|
||||
} else if (currentConfigType === 'simple') {
|
||||
showStep('setup-step-simple');
|
||||
} else if (currentConfigType === 'public') {
|
||||
showStep('setup-step-public');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Finish setup button
|
||||
const finishBtn = document.getElementById('setup-finish');
|
||||
if (finishBtn) {
|
||||
finishBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
finishSetup();
|
||||
};
|
||||
}
|
||||
|
||||
// Expose function to get global config (from server or localStorage)
|
||||
window.getGlobalConfig = async function() {
|
||||
// Try server first
|
||||
try {
|
||||
const response = await fetch('/api/v1/config');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
if (config && config.setupComplete) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not fetch config from server');
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
const configStr = safeGet('dashcaddy-config');
|
||||
if (configStr) {
|
||||
return JSON.parse(configStr);
|
||||
}
|
||||
|
||||
// Return default config if not set
|
||||
return {
|
||||
setupComplete: false,
|
||||
configurationType: 'homelab',
|
||||
tld: '.home',
|
||||
caName: '',
|
||||
defaults: {
|
||||
dnsType: 'private',
|
||||
sslType: 'internal',
|
||||
targetIP: 'localhost'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Expose reset function for settings
|
||||
window.resetSetupWizard = async function() {
|
||||
if (confirm('Reset DashCaddy configuration? This will show the setup wizard again.')) {
|
||||
try {
|
||||
await secureFetch('/api/v1/config', { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
console.warn('Could not delete server config');
|
||||
}
|
||||
safeRemove('dashcaddy-setup');
|
||||
safeRemove('dashcaddy-config');
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
})();
|
||||
56
status/js/skeleton-loader.js
Normal file
56
status/js/skeleton-loader.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// ========== SKELETON LOADING PLACEHOLDERS ==========
|
||||
(function () {
|
||||
|
||||
function createSkeletonCard() {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'skeleton-card';
|
||||
card.innerHTML =
|
||||
'<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">' +
|
||||
'<div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div>' +
|
||||
'<div style="flex:1">' +
|
||||
'<div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div>' +
|
||||
'<div class="skeleton-bar" style="width:35%;height:10px"></div>' +
|
||||
'</div>' +
|
||||
'<div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div>' +
|
||||
'</div>' +
|
||||
'<div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div>' +
|
||||
'<div style="display:flex;gap:8px;margin-top:auto">' +
|
||||
'<div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div>' +
|
||||
'<div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div>' +
|
||||
'</div>';
|
||||
return card;
|
||||
}
|
||||
|
||||
function showSkeletons(count) {
|
||||
const grid = document.getElementById('cards');
|
||||
if (!grid || grid.querySelector('.card')) return; // Don't show if real cards exist
|
||||
count = count || 6;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sk = createSkeletonCard();
|
||||
grid.appendChild(sk);
|
||||
// Stagger fade-in
|
||||
setTimeout(function () { sk.classList.add('loaded'); }, i * 60);
|
||||
}
|
||||
}
|
||||
|
||||
function hideSkeletons() {
|
||||
const grid = document.getElementById('cards');
|
||||
if (!grid) return;
|
||||
var skeletons = grid.querySelectorAll('.skeleton-card');
|
||||
if (!skeletons.length) return;
|
||||
|
||||
skeletons.forEach(function (sk, i) {
|
||||
setTimeout(function () {
|
||||
sk.style.opacity = '0';
|
||||
sk.style.transform = 'translateY(-10px)';
|
||||
}, i * 25);
|
||||
});
|
||||
// Remove after animation
|
||||
setTimeout(function () {
|
||||
skeletons.forEach(function (sk) { if (sk.parentNode) sk.remove(); });
|
||||
}, skeletons.length * 25 + 300);
|
||||
}
|
||||
|
||||
window.SkeletonLoader = { show: showSkeletons, hide: hideSkeletons };
|
||||
})();
|
||||
431
status/js/smart-arr-connect.js
Normal file
431
status/js/smart-arr-connect.js
Normal file
@@ -0,0 +1,431 @@
|
||||
// ========== SMART ARR CONNECT ==========
|
||||
(function() {
|
||||
injectModal('arr-setup-modal', `<div id="arr-setup-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
|
||||
<h3>🎬 Smart Arr Connect</h3>
|
||||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||||
Auto-discover and connect your entire media stack.
|
||||
</p>
|
||||
|
||||
<!-- Phase 1: Detection -->
|
||||
<div id="smart-phase-detect">
|
||||
<div style="text-align: center; padding: 20px;">
|
||||
<span class="brand-spinner" style="margin-bottom: 12px; display: inline-block;"></span>
|
||||
<div style="color: var(--muted); font-size: 0.9rem;">Scanning for services...</div>
|
||||
</div>
|
||||
<div id="smart-detect-results" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: Credential Input (only for services needing keys) -->
|
||||
<div id="smart-phase-credentials" style="display: none;">
|
||||
<h4 class="heading-accent-section">Enter Missing API Keys</h4>
|
||||
<div id="smart-credential-inputs"></div>
|
||||
|
||||
<!-- Connection Options -->
|
||||
<div style="margin-top: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 10px; font-size: 0.85rem; color: var(--accent);">Connection Options</h4>
|
||||
<label class="option-label">
|
||||
<input type="checkbox" id="smart-opt-seerr" checked class="checkbox-sm" />
|
||||
Configure Seerr with Radarr + Sonarr
|
||||
</label>
|
||||
<label class="option-label">
|
||||
<input type="checkbox" id="smart-opt-plex" checked class="checkbox-sm" />
|
||||
Connect Plex to Seerr
|
||||
</label>
|
||||
<label class="option-label">
|
||||
<input type="checkbox" id="smart-opt-prowlarr" checked class="checkbox-sm" />
|
||||
Connect Prowlarr to Radarr + Sonarr
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.85rem;">
|
||||
<input type="checkbox" id="smart-opt-save" checked class="checkbox-sm" />
|
||||
Save API keys for one-click reconnect
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button id="smart-connect-btn" style="width: 100%; margin-top: 16px; padding: 12px; background: linear-gradient(135deg, #e74c3c 0%, #9b59b6 100%); border: none; color: white; font-weight: 600; font-size: 1rem; border-radius: 8px; cursor: pointer;">
|
||||
Smart Connect
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3: Connection Progress -->
|
||||
<div id="smart-phase-progress" style="display: none;">
|
||||
<h4 class="heading-accent-section">Connecting Everything...</h4>
|
||||
<div id="smart-progress-steps" style="display: flex; flex-direction: column; gap: 6px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 4: Results -->
|
||||
<div id="smart-phase-results" style="display: none;">
|
||||
<div id="smart-results-content"></div>
|
||||
<div id="smart-plex-libraries" style="display: none; margin-top: 12px;"></div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||||
<button id="smart-retry-btn" style="display: none; flex: 1; padding: 10px; background: #f39c12; border: none; color: white; font-weight: 500; border-radius: 8px; cursor: pointer;">
|
||||
Retry Failed Steps
|
||||
</button>
|
||||
<a id="smart-open-seerr" href="#" target="_blank" rel="noopener noreferrer"
|
||||
style="flex: 1; padding: 10px; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; text-decoration: none; text-align: center;">
|
||||
Open Seerr
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 5%, transparent); border-radius: 6px;">
|
||||
<strong>Where to find API keys:</strong><br>
|
||||
Radarr/Sonarr/Prowlarr: Settings -> General -> Security -> API Key
|
||||
</div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="arr-setup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('arr-setup-modal');
|
||||
const openBtn = document.getElementById('arr-setup-btn');
|
||||
const cancelBtn = document.getElementById('arr-setup-cancel');
|
||||
const connectBtn = document.getElementById('smart-connect-btn');
|
||||
|
||||
// Phase elements
|
||||
const phaseDetect = document.getElementById('smart-phase-detect');
|
||||
const phaseCredentials = document.getElementById('smart-phase-credentials');
|
||||
const phaseProgress = document.getElementById('smart-phase-progress');
|
||||
const phaseResults = document.getElementById('smart-phase-results');
|
||||
|
||||
const detectResults = document.getElementById('smart-detect-results');
|
||||
const credentialInputs = document.getElementById('smart-credential-inputs');
|
||||
const progressSteps = document.getElementById('smart-progress-steps');
|
||||
const resultsContent = document.getElementById('smart-results-content');
|
||||
const plexLibraries = document.getElementById('smart-plex-libraries');
|
||||
const retryBtn = document.getElementById('smart-retry-btn');
|
||||
|
||||
let detectedData = null; // Store detection results for smart-connect
|
||||
|
||||
const serviceIcons = { plex: '🎬', radarr: '🎬', sonarr: '📺', prowlarr: '🔍', seerr: '📋' };
|
||||
const serviceLabels = { plex: 'Plex', radarr: 'Radarr (Movies)', sonarr: 'Sonarr (TV)', prowlarr: 'Prowlarr (Indexers)', seerr: 'Seerr' };
|
||||
|
||||
function showPhase(phase) {
|
||||
phaseDetect.style.display = phase === 'detect' ? 'block' : 'none';
|
||||
phaseCredentials.style.display = phase === 'credentials' ? 'block' : 'none';
|
||||
phaseProgress.style.display = phase === 'progress' ? 'block' : 'none';
|
||||
phaseResults.style.display = phase === 'results' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const colors = {
|
||||
connected: { bg: 'var(--ok-fg)', icon: '✓', text: 'Connected' },
|
||||
needs_key: { bg: '#f39c12', icon: '🔑', text: 'Needs API Key' },
|
||||
not_found: { bg: 'var(--muted)', icon: '—', text: 'Not Found' },
|
||||
error: { bg: 'var(--bad-fg)', icon: '✗', text: 'Error' }
|
||||
};
|
||||
const c = colors[status] || colors.not_found;
|
||||
return `<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${c.bg} 20%, transparent); color: ${c.bg};">${c.icon} ${c.text}</span>`;
|
||||
}
|
||||
|
||||
// Phase 1: Smart Detection
|
||||
async function smartDetect() {
|
||||
showPhase('detect');
|
||||
detectResults.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/arr/smart-detect');
|
||||
detectedData = await response.json();
|
||||
|
||||
if (!detectedData.success) {
|
||||
detectResults.innerHTML = `<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(detectedData.error)}</div>`;
|
||||
detectResults.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
// Render detection results
|
||||
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||||
|
||||
for (const [svc, info] of Object.entries(detectedData.services)) {
|
||||
const icon = serviceIcons[svc] || '📦';
|
||||
const label = serviceLabels[svc] || svc;
|
||||
const source = info.source ? `<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(info.source)}</span>` : '';
|
||||
const version = info.version ? `<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(info.version)}</span>` : '';
|
||||
const keySaved = (info.hasApiKey || info.hasToken) && info.status === 'connected'
|
||||
? '<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>' : '';
|
||||
|
||||
html += `<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${icon}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${label}</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
${source} ${version} ${keySaved}
|
||||
</div>
|
||||
</div>
|
||||
${statusBadge(info.status)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Summary
|
||||
const s = detectedData.summary;
|
||||
html += `<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected + (5 - s.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey > 0 ? ` · <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>` : ''}
|
||||
</div>`;
|
||||
|
||||
detectResults.innerHTML = html;
|
||||
detectResults.style.display = 'block';
|
||||
|
||||
// Build credential inputs for services that need keys
|
||||
buildCredentialInputs(detectedData);
|
||||
|
||||
// Auto-advance after a short delay
|
||||
setTimeout(() => {
|
||||
showPhase('credentials');
|
||||
}, 800);
|
||||
|
||||
} catch (e) {
|
||||
detectResults.innerHTML = `<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(e.message)}</div>`;
|
||||
detectResults.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Build credential input fields
|
||||
function buildCredentialInputs(data) {
|
||||
let html = '';
|
||||
const services = data.services;
|
||||
const arrServices = ['radarr', 'sonarr', 'prowlarr'];
|
||||
|
||||
for (const svc of arrServices) {
|
||||
const info = services[svc];
|
||||
if (!info || info.status === 'not_found' && !info.url) continue;
|
||||
|
||||
const icon = serviceIcons[svc];
|
||||
const label = serviceLabels[svc];
|
||||
const isConnected = info.status === 'connected';
|
||||
const borderColor = isConnected ? 'var(--ok-fg)' : 'var(--border)';
|
||||
|
||||
html += `<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${borderColor};">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="font-size: 1.1rem;">${icon}</span>
|
||||
<span style="font-weight: 500;">${label}</span>
|
||||
<span id="smart-${svc}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
${isConnected ? '<span style="color: var(--ok-fg);">✓ Connected</span>' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
||||
<input type="text" id="smart-${svc}-url" value="${escapeHtml(info.url || '')}" placeholder="https://seedbox.com/${svc}/"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
||||
<input type="password" id="smart-${svc}-key" placeholder="${isConnected ? '(saved)' : 'Paste API key'}"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="smartTestConnection('${svc}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Plex status (non-editable, just shows status)
|
||||
const plex = services.plex;
|
||||
if (plex) {
|
||||
const plexConnected = plex.status === 'connected';
|
||||
html += `<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${plexConnected ? 'var(--ok-fg)' : 'var(--border)'}; margin-bottom: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">🎬</span>
|
||||
<span style="font-weight: 500;">Plex</span>
|
||||
${statusBadge(plex.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(plex.source || '')}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Seerr status
|
||||
const seerr = services.seerr;
|
||||
if (seerr) {
|
||||
const seerrOk = seerr.status === 'connected';
|
||||
let configuredHtml = '';
|
||||
if (seerr.configuredServices) {
|
||||
const cs = seerr.configuredServices;
|
||||
configuredHtml = `<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
Configured: ${cs.radarr ? '✓ Radarr' : '✗ Radarr'} ·
|
||||
${cs.sonarr ? '✓ Sonarr' : '✗ Sonarr'} ·
|
||||
${cs.plex ? '✓ Plex' : '✗ Plex'}
|
||||
</div>`;
|
||||
}
|
||||
html += `<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${seerrOk ? 'var(--ok-fg)' : 'var(--border)'};">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">📋</span>
|
||||
<span style="font-weight: 500;">Seerr</span>
|
||||
${statusBadge(seerr.status)}
|
||||
</div>
|
||||
${configuredHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
credentialInputs.innerHTML = html;
|
||||
}
|
||||
|
||||
// Test connection (global for onclick)
|
||||
window.smartTestConnection = async function(service) {
|
||||
const urlInput = document.getElementById(`smart-${service}-url`);
|
||||
const keyInput = document.getElementById(`smart-${service}-key`);
|
||||
const statusSpan = document.getElementById(`smart-${service}-status`);
|
||||
|
||||
const url = urlInput?.value.trim();
|
||||
const apiKey = keyInput?.value.trim();
|
||||
|
||||
if (!url || !apiKey) {
|
||||
statusSpan.innerHTML = '<span style="color: var(--bad-fg);">Enter URL and API key</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
statusSpan.innerHTML = '<span class="brand-spinner"></span>';
|
||||
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/arr/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service, url, apiKey })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
statusSpan.innerHTML = `<span style="color: var(--ok-fg);">✓ ${escapeHtml(data.appName || 'Connected')} v${escapeHtml(data.version || '')}</span>`;
|
||||
} else {
|
||||
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">✗ ${escapeHtml(data.error)}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">✗ ${escapeHtml(e.message)}</span>`;
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 3: Smart Connect
|
||||
async function smartConnect() {
|
||||
showPhase('progress');
|
||||
progressSteps.innerHTML = '<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';
|
||||
|
||||
// Gather input
|
||||
const services = {};
|
||||
for (const svc of ['radarr', 'sonarr', 'prowlarr']) {
|
||||
const url = document.getElementById(`smart-${svc}-url`)?.value.trim();
|
||||
const apiKey = document.getElementById(`smart-${svc}-key`)?.value.trim();
|
||||
if (apiKey && url) {
|
||||
services[svc] = { apiKey, url };
|
||||
} else if (apiKey) {
|
||||
// Key provided without URL - let backend resolve
|
||||
services[svc] = { apiKey };
|
||||
}
|
||||
// If no key entered but service was already connected, backend uses stored credentials
|
||||
}
|
||||
|
||||
const payload = {
|
||||
services: Object.keys(services).length > 0 ? services : undefined,
|
||||
configurePlex: document.getElementById('smart-opt-plex')?.checked,
|
||||
configureProwlarr: document.getElementById('smart-opt-prowlarr')?.checked,
|
||||
configureSeerr: document.getElementById('smart-opt-seerr')?.checked,
|
||||
saveCredentials: document.getElementById('smart-opt-save')?.checked
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/arr/smart-connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Render progress steps
|
||||
let stepsHtml = '';
|
||||
for (const step of (data.steps || [])) {
|
||||
const icon = step.status === 'success' ? '<span style="color: var(--ok-fg);">✓</span>'
|
||||
: '<span style="color: var(--bad-fg);">✗</span>';
|
||||
const detailColor = step.status === 'success' ? 'var(--muted)' : 'var(--bad-fg)';
|
||||
stepsHtml += `<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${icon}
|
||||
<span>${escapeHtml(step.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${detailColor};">${escapeHtml(step.details || '')}</span>
|
||||
</div>`;
|
||||
}
|
||||
progressSteps.innerHTML = stepsHtml;
|
||||
|
||||
// Show results after brief delay
|
||||
setTimeout(() => showResults(data), 500);
|
||||
|
||||
} catch (e) {
|
||||
progressSteps.innerHTML = `<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Results
|
||||
function showResults(data) {
|
||||
showPhase('results');
|
||||
|
||||
const s = data.summary || {};
|
||||
const allGood = s.failed === 0 && s.succeeded > 0;
|
||||
const headerColor = allGood ? 'var(--ok-fg)' : '#f39c12';
|
||||
const headerIcon = allGood ? '✓' : '⚠';
|
||||
const headerText = allGood ? 'All Connected!' : `${escapeHtml(String(s.succeeded))}/${escapeHtml(String(s.totalSteps))} Steps Succeeded`;
|
||||
|
||||
let html = `<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${headerColor} 12%, transparent); border-radius: 10px; border: 1px solid ${headerColor}; margin-bottom: 12px;">
|
||||
<div style="font-size: 1.5rem; color: ${headerColor};">${headerIcon}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${headerColor};">${headerText}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(s.succeeded))} succeeded, ${escapeHtml(String(s.failed))} failed</div>
|
||||
</div>`;
|
||||
|
||||
// Steps detail
|
||||
html += '<div style="display: flex; flex-direction: column; gap: 4px;">';
|
||||
for (const step of (data.steps || [])) {
|
||||
const icon = step.status === 'success' ? '<span style="color: var(--ok-fg);">✓</span>'
|
||||
: '<span style="color: var(--bad-fg);">✗</span>';
|
||||
html += `<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
${icon} ${escapeHtml(step.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(step.details || '')}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
resultsContent.innerHTML = html;
|
||||
|
||||
// Show retry button if any failures
|
||||
retryBtn.style.display = s.failed > 0 ? 'block' : 'none';
|
||||
|
||||
// Fetch Plex libraries if Plex was connected
|
||||
if (data.steps?.some(st => st.step.includes('Plex') && st.status === 'success')) {
|
||||
fetchPlexLibraries();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlexLibraries() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/plex/libraries');
|
||||
const data = await res.json();
|
||||
if (data.success && data.libraries?.length > 0) {
|
||||
let html = `<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">🎬 ${escapeHtml(data.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;
|
||||
for (const lib of data.libraries) {
|
||||
const typeIcon = lib.type === 'movie' ? '🎬' : lib.type === 'show' ? '📺' : '🎵';
|
||||
html += `<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${typeIcon} <strong>${escapeHtml(lib.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(lib.count))} items</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div></div>';
|
||||
plexLibraries.innerHTML = html;
|
||||
plexLibraries.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore Plex library fetch errors
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
plexLibraries.style.display = 'none';
|
||||
smartDetect();
|
||||
});
|
||||
|
||||
wireModal(modal, cancelBtn);
|
||||
connectBtn?.addEventListener('click', smartConnect);
|
||||
retryBtn?.addEventListener('click', smartConnect);
|
||||
})();
|
||||
307
status/js/theme-adapter.js
Normal file
307
status/js/theme-adapter.js
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Theme Adapter
|
||||
* Ensures tooltips match the current dashboard theme
|
||||
* Integrates with Driver.js to apply theme-specific styling
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Theme configuration mapping for Driver.js
|
||||
* Maps dashboard themes to Driver.js styling
|
||||
*/
|
||||
const THEME_CONFIGS = {
|
||||
dark: {
|
||||
backgroundColor: 'var(--card-base)',
|
||||
textColor: 'var(--fg)',
|
||||
primaryColor: 'var(--accent)',
|
||||
overlayColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderColor: 'var(--border)',
|
||||
highlightColor: 'var(--accent)',
|
||||
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
},
|
||||
light: {
|
||||
backgroundColor: 'var(--card-base)',
|
||||
textColor: 'var(--fg)',
|
||||
primaryColor: 'var(--accent-strong)',
|
||||
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderColor: 'var(--border)',
|
||||
highlightColor: 'var(--accent-strong)',
|
||||
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
},
|
||||
blue: {
|
||||
backgroundColor: 'var(--card-base)',
|
||||
textColor: 'var(--fg)',
|
||||
primaryColor: 'var(--accent)',
|
||||
overlayColor: 'rgba(25, 8, 172, 0.7)',
|
||||
borderColor: 'var(--border)',
|
||||
highlightColor: 'var(--accent)',
|
||||
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
},
|
||||
nord: {
|
||||
backgroundColor: 'var(--card-base)',
|
||||
textColor: 'var(--fg)',
|
||||
primaryColor: 'var(--accent)',
|
||||
overlayColor: 'rgba(46, 52, 64, 0.7)',
|
||||
borderColor: 'var(--border)',
|
||||
highlightColor: 'var(--accent)',
|
||||
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
},
|
||||
dracula: {
|
||||
backgroundColor: 'var(--card-base)',
|
||||
textColor: 'var(--fg)',
|
||||
primaryColor: 'var(--accent)',
|
||||
overlayColor: 'rgba(40, 42, 54, 0.7)',
|
||||
borderColor: 'var(--border)',
|
||||
highlightColor: 'var(--accent)',
|
||||
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
},
|
||||
'solarized-dark': {
|
||||
backgroundColor: 'var(--card-base)',
|
||||
textColor: 'var(--fg)',
|
||||
primaryColor: 'var(--accent)',
|
||||
overlayColor: 'rgba(0, 43, 54, 0.7)',
|
||||
borderColor: 'var(--border)',
|
||||
highlightColor: 'var(--accent)',
|
||||
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
},
|
||||
'solarized-light': {
|
||||
backgroundColor: 'var(--card-base)',
|
||||
textColor: 'var(--fg)',
|
||||
primaryColor: 'var(--accent)',
|
||||
overlayColor: 'rgba(253, 246, 227, 0.7)',
|
||||
borderColor: 'var(--border)',
|
||||
highlightColor: 'var(--accent)',
|
||||
fontFamily: "'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ThemeAdapter class
|
||||
* Manages theme integration for the tooltip system
|
||||
*/
|
||||
class ThemeAdapter {
|
||||
constructor() {
|
||||
this.currentTheme = this.getCurrentTheme();
|
||||
this.themeChangeCallbacks = [];
|
||||
this._setupThemeChangeListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current theme name from document root class
|
||||
* @returns {string} Current theme name (e.g., 'dark', 'light', 'blue')
|
||||
*/
|
||||
getCurrentTheme() {
|
||||
const root = document.documentElement;
|
||||
const classList = Array.from(root.classList);
|
||||
|
||||
// Check all known themes (built-in + user) except 'dark' (no class = dark)
|
||||
const allThemes = (window.THEMES || []).filter(t => t !== 'dark');
|
||||
const foundTheme = allThemes.find(theme => classList.includes(theme));
|
||||
|
||||
return foundTheme || 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Driver.js theme configuration for current theme
|
||||
* @returns {Object} Theme configuration object
|
||||
*/
|
||||
getDriverTheme() {
|
||||
const themeName = this.getCurrentTheme();
|
||||
const config = THEME_CONFIGS[themeName] || THEME_CONFIGS.dark;
|
||||
|
||||
// Resolve CSS variables to actual values
|
||||
const resolvedConfig = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (typeof value === 'string' && value.startsWith('var(')) {
|
||||
// Extract CSS variable name
|
||||
const varName = value.match(/var\((--[^)]+)\)/)?.[1];
|
||||
if (varName) {
|
||||
const computedValue = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
resolvedConfig[key] = computedValue || value;
|
||||
} else {
|
||||
resolvedConfig[key] = value;
|
||||
}
|
||||
} else {
|
||||
resolvedConfig[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for theme changes
|
||||
* @param {Function} callback - Function to call when theme changes
|
||||
*/
|
||||
onThemeChange(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.themeChangeCallbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup theme change listener using MutationObserver
|
||||
* @private
|
||||
*/
|
||||
_setupThemeChangeListener() {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Create observer to watch for class changes on root element
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||
const newTheme = this.getCurrentTheme();
|
||||
if (newTheme !== this.currentTheme) {
|
||||
const oldTheme = this.currentTheme;
|
||||
this.currentTheme = newTheme;
|
||||
this._notifyThemeChange(newTheme, oldTheme);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start observing
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
console.log('[ThemeAdapter] Theme change listener initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered callbacks of theme change
|
||||
* @private
|
||||
* @param {string} newTheme - New theme name
|
||||
* @param {string} oldTheme - Old theme name
|
||||
*/
|
||||
_notifyThemeChange(newTheme, oldTheme) {
|
||||
console.log(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`);
|
||||
|
||||
this.themeChangeCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(newTheme, oldTheme);
|
||||
} catch (error) {
|
||||
console.error('[ThemeAdapter] Error in theme change callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme to Driver.js instance
|
||||
* @param {Object} driver - Driver.js instance
|
||||
*/
|
||||
applyTheme(driver) {
|
||||
if (!driver) {
|
||||
console.warn('[ThemeAdapter] No driver instance provided');
|
||||
return;
|
||||
}
|
||||
|
||||
const themeConfig = this.getDriverTheme();
|
||||
|
||||
// Apply theme configuration to driver
|
||||
// Note: Driver.js v1.0+ uses CSS variables, so we inject a style element
|
||||
this._injectDriverStyles(themeConfig);
|
||||
|
||||
console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject custom styles for Driver.js based on theme
|
||||
* @private
|
||||
* @param {Object} themeConfig - Theme configuration
|
||||
*/
|
||||
_injectDriverStyles(themeConfig) {
|
||||
// Remove existing theme styles
|
||||
const existingStyle = document.getElementById('driver-theme-styles');
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
|
||||
// Create new style element
|
||||
const style = document.createElement('style');
|
||||
style.id = 'driver-theme-styles';
|
||||
style.textContent = `
|
||||
.driver-popover {
|
||||
background: ${themeConfig.backgroundColor} !important;
|
||||
color: ${themeConfig.textColor} !important;
|
||||
border: 1px solid ${themeConfig.borderColor} !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important;
|
||||
font-family: ${themeConfig.fontFamily} !important;
|
||||
}
|
||||
|
||||
.driver-popover-title {
|
||||
color: ${themeConfig.textColor} !important;
|
||||
font-weight: 600 !important;
|
||||
font-family: ${themeConfig.fontFamily} !important;
|
||||
}
|
||||
|
||||
.driver-popover-description {
|
||||
color: ${themeConfig.textColor} !important;
|
||||
font-family: ${themeConfig.fontFamily} !important;
|
||||
}
|
||||
|
||||
.driver-popover-footer button {
|
||||
background: ${themeConfig.primaryColor} !important;
|
||||
color: ${themeConfig.backgroundColor} !important;
|
||||
border: none !important;
|
||||
font-family: ${themeConfig.fontFamily} !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.driver-popover-footer button:hover {
|
||||
opacity: 0.9 !important;
|
||||
}
|
||||
|
||||
.driver-popover-close-btn {
|
||||
color: ${themeConfig.textColor} !important;
|
||||
}
|
||||
|
||||
.driver-overlay {
|
||||
background: ${themeConfig.overlayColor} !important;
|
||||
}
|
||||
|
||||
.driver-highlighted-element {
|
||||
outline: 2px solid ${themeConfig.highlightColor} !important;
|
||||
outline-offset: 4px !important;
|
||||
}
|
||||
|
||||
.driver-popover-progress-text {
|
||||
color: ${themeConfig.textColor} !important;
|
||||
opacity: 0.7 !important;
|
||||
font-family: ${themeConfig.fontFamily} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available theme names
|
||||
* @returns {string[]} Array of theme names
|
||||
*/
|
||||
getAvailableThemes() {
|
||||
return Object.keys(THEME_CONFIGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a theme is available
|
||||
* @param {string} themeName - Theme name to check
|
||||
* @returns {boolean} True if theme is available
|
||||
*/
|
||||
isThemeAvailable(themeName) {
|
||||
return THEME_CONFIGS.hasOwnProperty(themeName);
|
||||
}
|
||||
}
|
||||
|
||||
// Export to global scope
|
||||
window.ThemeAdapter = ThemeAdapter;
|
||||
|
||||
console.log('[ThemeAdapter] Module loaded');
|
||||
|
||||
})(window);
|
||||
637
status/js/theme-builder.js
Normal file
637
status/js/theme-builder.js
Normal file
@@ -0,0 +1,637 @@
|
||||
// ========== THEME PICKER + THEME BUILDER ==========
|
||||
(function () {
|
||||
var themeBeforeBuilder = null;
|
||||
var editingSlug = null;
|
||||
var advancedOverrides = {}; // tracks which advanced fields user manually changed
|
||||
|
||||
// Display names for built-in themes
|
||||
var THEME_LABELS = {
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
blue: 'Blue',
|
||||
black: 'Black',
|
||||
nord: 'Nord',
|
||||
dracula: 'Dracula',
|
||||
'solarized-dark': 'Solarized Dark',
|
||||
'solarized-light': 'Solarized Light',
|
||||
taxi: 'Taxi',
|
||||
ocean: 'Ocean',
|
||||
};
|
||||
|
||||
// Color picker field definitions: [css-prop, label, section]
|
||||
var FIELDS = [
|
||||
['bg', 'Background', 'base'],
|
||||
['card-base', 'Card', 'base'],
|
||||
['fg', 'Text', 'base'],
|
||||
['muted', 'Muted Text', 'base'],
|
||||
['border', 'Border', 'base'],
|
||||
['accent', 'Accent', 'accent'],
|
||||
['accent-strong', 'Accent Strong', 'accent'],
|
||||
['ok-bg', 'OK Background', 'status'],
|
||||
['ok-fg', 'OK Text', 'status'],
|
||||
['bad-bg', 'Error Bg', 'status'],
|
||||
['bad-fg', 'Error Text', 'status'],
|
||||
['dot-ok', 'Dot OK', 'status'],
|
||||
['dot-bad', 'Dot Error', 'status'],
|
||||
['uptime', 'Uptime Bar', 'status'],
|
||||
['hover', 'Hover', 'advanced'],
|
||||
['card-hover', 'Card Hover', 'advanced'],
|
||||
['base', 'Tags/Badges', 'advanced'],
|
||||
['fg-muted', 'Dim Text', 'advanced'],
|
||||
['success', 'Success', 'advanced'],
|
||||
['error', 'Error', 'advanced'],
|
||||
['warning', 'Warning', 'advanced'],
|
||||
];
|
||||
|
||||
// ─── Theme Cycle Button ───────────────────────────────
|
||||
|
||||
var themeBtn = document.getElementById('theme');
|
||||
if (!themeBtn) return;
|
||||
var themeLabel = document.getElementById('theme-label');
|
||||
|
||||
function getLabelFor(t) {
|
||||
if (THEME_LABELS[t]) return THEME_LABELS[t];
|
||||
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
|
||||
if (userThemes[t]) return userThemes[t].name || t;
|
||||
return t;
|
||||
}
|
||||
|
||||
function updateLabel() {
|
||||
if (themeLabel) themeLabel.textContent = getLabelFor(window.getActiveTheme());
|
||||
}
|
||||
|
||||
themeBtn.addEventListener('click', function () {
|
||||
var list = window.THEMES.slice();
|
||||
var current = window.getActiveTheme();
|
||||
var idx = list.indexOf(current);
|
||||
var next = list[(idx + 1) % list.length];
|
||||
window.applyTheme(next);
|
||||
updateLabel();
|
||||
});
|
||||
|
||||
updateLabel();
|
||||
|
||||
// ─── Theme Builder Modal ───────────────────────────────
|
||||
|
||||
function buildFieldsHTML() {
|
||||
var sections = { base: 'Base Colors', accent: 'Accent', status: 'Status', advanced: 'Advanced (auto-derived)' };
|
||||
var grouped = {};
|
||||
FIELDS.forEach(function (f) {
|
||||
if (!grouped[f[2]]) grouped[f[2]] = [];
|
||||
grouped[f[2]].push(f);
|
||||
});
|
||||
|
||||
var html = '';
|
||||
Object.keys(sections).forEach(function (key) {
|
||||
if (key === 'advanced') {
|
||||
html += '<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors ▼</div>';
|
||||
html += '<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">';
|
||||
} else {
|
||||
html += '<div class="theme-builder-section">';
|
||||
}
|
||||
html += '<div class="theme-builder-section-title">' + sections[key] + '</div>';
|
||||
(grouped[key] || []).forEach(function (f) {
|
||||
html += '<div class="theme-builder-row">' +
|
||||
'<span class="theme-builder-label">' + f[1] + '</span>' +
|
||||
'<input type="color" class="theme-builder-color" data-prop="' + f[0] + '"' + (key === 'advanced' ? ' data-advanced="1"' : '') + ' value="#000000" />' +
|
||||
'<span class="theme-builder-hex" data-hex="' + f[0] + '">#000000</span>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildStartFromOptions() {
|
||||
return window.THEMES.map(function (t) {
|
||||
return '<option value="' + t + '">' + getLabelFor(t) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
var modalHTML = '<div id="theme-builder-modal" class="weather-modal">' +
|
||||
'<div class="weather-modal-content" style="min-width:420px;max-width:560px;">' +
|
||||
'<h3>Theme Builder</h3>' +
|
||||
|
||||
// Edit existing user themes dropdown
|
||||
'<div id="theme-builder-existing" style="margin-bottom:16px;display:none;">' +
|
||||
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label>' +
|
||||
'<select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;">' +
|
||||
'<option value="">— New Theme —</option>' +
|
||||
'</select>' +
|
||||
'<button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button>' +
|
||||
'</div>' +
|
||||
|
||||
'<div style="margin-bottom:16px;">' +
|
||||
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label>' +
|
||||
'<input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" ' +
|
||||
'style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" />' +
|
||||
'</div>' +
|
||||
|
||||
'<div style="margin-bottom:16px;display:flex;align-items:center;">' +
|
||||
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg">' +
|
||||
'<input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />' +
|
||||
'Light background (use dark logo)</label>' +
|
||||
'</div>' +
|
||||
|
||||
'<div style="margin-bottom:16px;">' +
|
||||
'<label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label>' +
|
||||
'<select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">' +
|
||||
buildStartFromOptions() +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="theme-builder-preview" id="theme-builder-preview">' +
|
||||
'<div class="theme-builder-card" id="theme-preview-card">' +
|
||||
'<div class="preview-title">Sample Card</div>' +
|
||||
'<div class="preview-muted">Secondary text preview</div>' +
|
||||
'<div class="preview-badges">' +
|
||||
'<span class="preview-badge" id="preview-badge-ok">ON</span>' +
|
||||
'<span class="preview-badge" id="preview-badge-bad">OFF</span>' +
|
||||
'</div>' +
|
||||
'<div class="preview-dots">' +
|
||||
'<span><span class="preview-dot" id="preview-dot-ok"></span> Online</span>' +
|
||||
'<span><span class="preview-dot" id="preview-dot-bad"></span> Offline</span>' +
|
||||
'</div>' +
|
||||
'<button class="preview-btn" id="preview-accent-btn">Accent Button</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
buildFieldsHTML() +
|
||||
|
||||
'<div class="weather-modal-buttons">' +
|
||||
'<button id="theme-builder-cancel">Cancel</button>' +
|
||||
'<button id="theme-builder-import" style="margin-left:auto;" title="Import theme from JSON file">Import</button>' +
|
||||
'<button id="theme-builder-export" title="Export theme as JSON file">Export</button>' +
|
||||
'<button id="theme-builder-save" class="btn-accent">Save Theme</button>' +
|
||||
'</div>' +
|
||||
'</div></div>';
|
||||
|
||||
injectModal('theme-builder-modal', modalHTML);
|
||||
|
||||
var modal = document.getElementById('theme-builder-modal');
|
||||
var startSelect = document.getElementById('theme-builder-start');
|
||||
var nameInput = document.getElementById('theme-builder-name');
|
||||
var editSelect = document.getElementById('theme-builder-edit-select');
|
||||
var existingSection = document.getElementById('theme-builder-existing');
|
||||
var lightBgCheck = document.getElementById('theme-builder-lightbg');
|
||||
var pickers = modal.querySelectorAll('.theme-builder-color');
|
||||
var advancedSection = document.getElementById('theme-builder-advanced');
|
||||
var advancedToggle = document.getElementById('theme-builder-advanced-toggle');
|
||||
var exportBtn = document.getElementById('theme-builder-export');
|
||||
|
||||
// ─── Advanced toggle ───────────────────────────────
|
||||
|
||||
var advancedVisible = false;
|
||||
advancedToggle.addEventListener('click', function () {
|
||||
advancedVisible = !advancedVisible;
|
||||
advancedSection.style.display = advancedVisible ? '' : 'none';
|
||||
advancedToggle.innerHTML = advancedVisible
|
||||
? 'Hide advanced colors ▲'
|
||||
: 'Show advanced colors ▼';
|
||||
});
|
||||
|
||||
// ─── Color picker helpers ───────────────────────────────
|
||||
|
||||
function getCurrentColors() {
|
||||
var colors = {};
|
||||
pickers.forEach(function (p) {
|
||||
colors[p.dataset.prop] = p.value;
|
||||
});
|
||||
colors['card-bg'] = colors['card-base'];
|
||||
return colors;
|
||||
}
|
||||
|
||||
function getBaseColors() {
|
||||
var colors = {};
|
||||
pickers.forEach(function (p) {
|
||||
if (!p.dataset.advanced) colors[p.dataset.prop] = p.value;
|
||||
});
|
||||
colors['card-bg'] = colors['card-base'];
|
||||
if (lightBgCheck.checked) colors.lightBg = true;
|
||||
return colors;
|
||||
}
|
||||
|
||||
function updateAdvancedFromDerived() {
|
||||
var base = getBaseColors();
|
||||
var derived = window.deriveExtendedColors ? window.deriveExtendedColors(base) : {};
|
||||
pickers.forEach(function (p) {
|
||||
if (p.dataset.advanced && !advancedOverrides[p.dataset.prop]) {
|
||||
var val = derived[p.dataset.prop] || '#333333';
|
||||
p.value = val;
|
||||
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
|
||||
if (hex) hex.textContent = val.toUpperCase();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadThemeIntoPickers(themeName) {
|
||||
var colors = window.THEME_COLORS[themeName];
|
||||
if (!colors) return;
|
||||
advancedOverrides = {};
|
||||
pickers.forEach(function (p) {
|
||||
var val = colors[p.dataset.prop] || '#000000';
|
||||
if (val.startsWith('rgba') || val.startsWith('color-mix')) val = '#333333';
|
||||
p.value = val;
|
||||
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
|
||||
if (hex) hex.textContent = val.toUpperCase();
|
||||
});
|
||||
lightBgCheck.checked = !!colors.lightBg;
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function loadUserThemeIntoPickers(slug) {
|
||||
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
|
||||
var theme = userThemes[slug];
|
||||
if (!theme) return;
|
||||
advancedOverrides = {};
|
||||
pickers.forEach(function (p) {
|
||||
var val = theme[p.dataset.prop] || '#000000';
|
||||
p.value = val;
|
||||
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
|
||||
if (hex) hex.textContent = val.toUpperCase();
|
||||
// If theme has explicit advanced values, mark as overridden
|
||||
if (p.dataset.advanced && theme[p.dataset.prop]) {
|
||||
advancedOverrides[p.dataset.prop] = true;
|
||||
}
|
||||
});
|
||||
nameInput.value = theme.name || '';
|
||||
lightBgCheck.checked = !!theme.lightBg;
|
||||
// Fill any missing advanced fields from derivation
|
||||
updateAdvancedFromDerived();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
var c = getCurrentColors();
|
||||
var preview = document.getElementById('theme-builder-preview');
|
||||
var card = document.getElementById('theme-preview-card');
|
||||
var title = card.querySelector('.preview-title');
|
||||
var muted = card.querySelector('.preview-muted');
|
||||
var badgeOk = document.getElementById('preview-badge-ok');
|
||||
var badgeBad = document.getElementById('preview-badge-bad');
|
||||
var dotOk = document.getElementById('preview-dot-ok');
|
||||
var dotBad = document.getElementById('preview-dot-bad');
|
||||
var btn = document.getElementById('preview-accent-btn');
|
||||
var dots = card.querySelector('.preview-dots');
|
||||
|
||||
preview.style.background = c['bg'];
|
||||
card.style.background = c['card-base'];
|
||||
card.style.borderColor = c['border'];
|
||||
title.style.color = c['fg'];
|
||||
muted.style.color = c['muted'];
|
||||
badgeOk.style.background = c['ok-bg'];
|
||||
badgeOk.style.color = c['ok-fg'];
|
||||
badgeBad.style.background = c['bad-bg'];
|
||||
badgeBad.style.color = c['bad-fg'];
|
||||
dotOk.style.background = c['dot-ok'];
|
||||
dotBad.style.background = c['dot-bad'];
|
||||
dots.style.color = c['fg'];
|
||||
btn.style.background = c['accent'];
|
||||
btn.style.color = c['bg'];
|
||||
}
|
||||
|
||||
function applyColorsLive(colors) {
|
||||
// Include derived colors for full live preview
|
||||
var derived = window.deriveExtendedColors ? window.deriveExtendedColors(colors) : {};
|
||||
window.THEME_PROPS.forEach(function (prop) {
|
||||
var val = colors[prop] || derived[prop];
|
||||
if (val) {
|
||||
document.documentElement.style.setProperty('--' + prop, val);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire up color pickers
|
||||
pickers.forEach(function (p) {
|
||||
p.addEventListener('input', function () {
|
||||
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
|
||||
if (hex) hex.textContent = p.value.toUpperCase();
|
||||
|
||||
// Track manual advanced overrides
|
||||
if (p.dataset.advanced) {
|
||||
advancedOverrides[p.dataset.prop] = true;
|
||||
} else {
|
||||
// Base color changed — re-derive advanced values (unless overridden)
|
||||
updateAdvancedFromDerived();
|
||||
}
|
||||
|
||||
updatePreview();
|
||||
applyColorsLive(getCurrentColors());
|
||||
});
|
||||
});
|
||||
|
||||
// Light-bg checkbox should re-derive advanced colors
|
||||
lightBgCheck.addEventListener('change', function () {
|
||||
updateAdvancedFromDerived();
|
||||
updatePreview();
|
||||
applyColorsLive(getCurrentColors());
|
||||
});
|
||||
|
||||
startSelect.addEventListener('change', function () {
|
||||
advancedOverrides = {};
|
||||
loadThemeIntoPickers(startSelect.value);
|
||||
applyColorsLive(getCurrentColors());
|
||||
});
|
||||
|
||||
// Populate and refresh the edit-existing dropdown
|
||||
function refreshEditDropdown() {
|
||||
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
|
||||
var slugs = Object.keys(userThemes);
|
||||
editSelect.innerHTML = '<option value="">— New Theme —</option>';
|
||||
slugs.forEach(function (slug) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = slug;
|
||||
opt.textContent = userThemes[slug].name || slug;
|
||||
editSelect.appendChild(opt);
|
||||
});
|
||||
existingSection.style.display = slugs.length ? '' : 'none';
|
||||
}
|
||||
|
||||
// Also refresh "Start from" options
|
||||
function refreshStartFrom() {
|
||||
startSelect.innerHTML = buildStartFromOptions();
|
||||
}
|
||||
|
||||
// Edit dropdown change
|
||||
editSelect.addEventListener('change', function () {
|
||||
var slug = this.value;
|
||||
if (slug) {
|
||||
editingSlug = slug;
|
||||
loadUserThemeIntoPickers(slug);
|
||||
applyColorsLive(getCurrentColors());
|
||||
} else {
|
||||
editingSlug = null;
|
||||
advancedOverrides = {};
|
||||
nameInput.value = '';
|
||||
startSelect.value = window.getActiveTheme();
|
||||
loadThemeIntoPickers(startSelect.value);
|
||||
}
|
||||
exportBtn.style.display = editingSlug ? '' : 'none';
|
||||
});
|
||||
|
||||
function openBuilder() {
|
||||
themeBeforeBuilder = window.getActiveTheme();
|
||||
editingSlug = null;
|
||||
advancedOverrides = {};
|
||||
|
||||
// Reset advanced section to collapsed
|
||||
advancedVisible = false;
|
||||
advancedSection.style.display = 'none';
|
||||
advancedToggle.innerHTML = 'Show advanced colors ▼';
|
||||
|
||||
refreshEditDropdown();
|
||||
refreshStartFrom();
|
||||
|
||||
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
|
||||
if (userThemes[themeBeforeBuilder]) {
|
||||
// Active theme is a user theme — open it for editing
|
||||
editSelect.value = themeBeforeBuilder;
|
||||
editingSlug = themeBeforeBuilder;
|
||||
loadUserThemeIntoPickers(themeBeforeBuilder);
|
||||
} else {
|
||||
editSelect.value = '';
|
||||
nameInput.value = '';
|
||||
startSelect.value = themeBeforeBuilder;
|
||||
loadThemeIntoPickers(themeBeforeBuilder);
|
||||
}
|
||||
|
||||
exportBtn.style.display = editingSlug ? '' : 'none';
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
var customizeBtn = document.getElementById('theme-customize-btn');
|
||||
if (customizeBtn) {
|
||||
customizeBtn.addEventListener('click', function () { openBuilder(); });
|
||||
}
|
||||
|
||||
// ─── Save ───────────────────────────────
|
||||
|
||||
document.getElementById('theme-builder-save').addEventListener('click', function () {
|
||||
var colors = getCurrentColors();
|
||||
var name = nameInput.value.trim();
|
||||
if (!name) {
|
||||
showNotification('Please enter a theme name', 'warning', 3000);
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
|
||||
var slug;
|
||||
var oldSlug = null;
|
||||
|
||||
if (editingSlug) {
|
||||
slug = editingSlug;
|
||||
// If name changed, re-slugify and potentially rename
|
||||
var newSlug = window.slugifyThemeName(name, editingSlug);
|
||||
if (newSlug !== editingSlug) {
|
||||
oldSlug = editingSlug;
|
||||
delete userThemes[editingSlug];
|
||||
var idx = window.THEMES.indexOf(editingSlug);
|
||||
if (idx !== -1) window.THEMES.splice(idx, 1);
|
||||
delete window.THEME_COLORS[editingSlug];
|
||||
slug = newSlug;
|
||||
}
|
||||
} else {
|
||||
slug = window.slugifyThemeName(name);
|
||||
}
|
||||
|
||||
// Build theme data with ALL color properties
|
||||
var themeData = { name: name };
|
||||
if (lightBgCheck.checked) themeData.lightBg = true;
|
||||
window.THEME_PROPS.forEach(function (p) {
|
||||
if (colors[p]) themeData[p] = colors[p];
|
||||
});
|
||||
|
||||
// Update localStorage cache immediately for instant UI
|
||||
userThemes[slug] = themeData;
|
||||
safeSet(window.USER_THEMES_KEY, JSON.stringify(userThemes));
|
||||
|
||||
// Clear inline preview properties
|
||||
window.clearCustomProperties();
|
||||
|
||||
// Re-inject CSS and apply
|
||||
window.injectUserThemeStyles();
|
||||
window.applyTheme(slug);
|
||||
modal.classList.remove('show');
|
||||
updateLabel();
|
||||
|
||||
// Save to server (source of truth)
|
||||
var apiColors = {};
|
||||
window.THEME_PROPS.forEach(function (p) { if (colors[p]) apiColors[p] = colors[p]; });
|
||||
|
||||
// If slug changed, delete the old one from server
|
||||
if (oldSlug) {
|
||||
secureFetch('/api/v1/themes/' + oldSlug, { method: 'DELETE' }).catch(function () {});
|
||||
}
|
||||
|
||||
secureFetch('/api/v1/themes/' + slug, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, colors: apiColors, lightBg: lightBgCheck.checked })
|
||||
}).then(function () {
|
||||
showNotification(name + ' theme saved', 'success', 3000);
|
||||
}).catch(function () {
|
||||
showNotification(name + ' theme saved locally (server sync failed)', 'warning', 3000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cancel ───────────────────────────────
|
||||
|
||||
document.getElementById('theme-builder-cancel').addEventListener('click', function () {
|
||||
modal.classList.remove('show');
|
||||
window.clearCustomProperties();
|
||||
if (themeBeforeBuilder) {
|
||||
window.applyTheme(themeBeforeBuilder);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Delete ───────────────────────────────
|
||||
|
||||
document.getElementById('theme-builder-delete').addEventListener('click', function () {
|
||||
if (!editingSlug) return;
|
||||
|
||||
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
|
||||
var name = userThemes[editingSlug] ? userThemes[editingSlug].name : editingSlug;
|
||||
|
||||
if (!confirm('Delete "' + name + '" theme?')) return;
|
||||
|
||||
var slugToDelete = editingSlug;
|
||||
|
||||
// Update localStorage cache immediately
|
||||
delete userThemes[slugToDelete];
|
||||
safeSet(window.USER_THEMES_KEY, JSON.stringify(userThemes));
|
||||
|
||||
// Remove from runtime
|
||||
var idx = window.THEMES.indexOf(slugToDelete);
|
||||
if (idx !== -1) window.THEMES.splice(idx, 1);
|
||||
delete window.THEME_COLORS[slugToDelete];
|
||||
|
||||
window.clearCustomProperties();
|
||||
window.injectUserThemeStyles();
|
||||
|
||||
var fallback = themeBeforeBuilder && themeBeforeBuilder !== slugToDelete ? themeBeforeBuilder : 'dark';
|
||||
window.applyTheme(fallback);
|
||||
|
||||
editingSlug = null;
|
||||
modal.classList.remove('show');
|
||||
updateLabel();
|
||||
|
||||
// Delete from server
|
||||
secureFetch('/api/v1/themes/' + slugToDelete, { method: 'DELETE' }).then(function () {
|
||||
showNotification(name + ' theme deleted', 'success', 3000);
|
||||
}).catch(function () {
|
||||
showNotification(name + ' theme deleted locally (server sync failed)', 'warning', 3000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Export ───────────────────────────────
|
||||
|
||||
document.getElementById('theme-builder-export').addEventListener('click', function () {
|
||||
if (!editingSlug) {
|
||||
showNotification('Save the theme first, then export', 'warning', 3000);
|
||||
return;
|
||||
}
|
||||
var userThemes = safeGetJSON(window.USER_THEMES_KEY, {});
|
||||
var theme = userThemes[editingSlug];
|
||||
if (!theme) return;
|
||||
|
||||
var exportData = {
|
||||
_dashcaddy_theme: true,
|
||||
version: '1.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
slug: editingSlug,
|
||||
name: theme.name,
|
||||
lightBg: theme.lightBg || false,
|
||||
colors: {}
|
||||
};
|
||||
window.THEME_PROPS.forEach(function (p) {
|
||||
if (theme[p]) exportData.colors[p] = theme[p];
|
||||
});
|
||||
|
||||
var blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = editingSlug + '-theme.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
showNotification('Theme exported as ' + editingSlug + '-theme.json', 'success', 3000);
|
||||
});
|
||||
|
||||
// ─── Import ───────────────────────────────
|
||||
|
||||
document.getElementById('theme-builder-import').addEventListener('click', function () {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = function (e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (ev) {
|
||||
try {
|
||||
var data = JSON.parse(ev.target.result);
|
||||
|
||||
// Support both wrapped format and raw theme format
|
||||
var themeName, themeColors, themeLightBg;
|
||||
if (data._dashcaddy_theme && data.colors) {
|
||||
// Wrapped export format
|
||||
themeName = data.name || 'Imported';
|
||||
themeColors = data.colors;
|
||||
themeLightBg = data.lightBg || false;
|
||||
} else if (data.name && (data.bg || data['card-base'])) {
|
||||
// Raw theme JSON (same format as server files)
|
||||
themeName = data.name;
|
||||
themeColors = {};
|
||||
window.THEME_PROPS.forEach(function (p) { if (data[p]) themeColors[p] = data[p]; });
|
||||
themeLightBg = data.lightBg || false;
|
||||
} else {
|
||||
throw new Error('Not a valid DashCaddy theme file');
|
||||
}
|
||||
|
||||
// Load into builder
|
||||
nameInput.value = themeName;
|
||||
lightBgCheck.checked = !!themeLightBg;
|
||||
editingSlug = null;
|
||||
editSelect.value = '';
|
||||
advancedOverrides = {};
|
||||
|
||||
// Apply colors to pickers
|
||||
pickers.forEach(function (p) {
|
||||
var val = themeColors[p.dataset.prop] || '#000000';
|
||||
p.value = val;
|
||||
var hex = modal.querySelector('[data-hex="' + p.dataset.prop + '"]');
|
||||
if (hex) hex.textContent = val.toUpperCase();
|
||||
if (p.dataset.advanced && themeColors[p.dataset.prop]) {
|
||||
advancedOverrides[p.dataset.prop] = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fill any missing advanced fields
|
||||
updateAdvancedFromDerived();
|
||||
updatePreview();
|
||||
applyColorsLive(getCurrentColors());
|
||||
exportBtn.style.display = 'none';
|
||||
showNotification('"' + themeName + '" loaded into builder. Click Save to keep it.', 'success', 5000);
|
||||
} catch (err) {
|
||||
showNotification('Import failed: ' + err.message, 'error', 5000);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
|
||||
// ─── Modal close handling ───────────────────────────────
|
||||
|
||||
wireModal(modal);
|
||||
|
||||
modal.addEventListener('click', function (e) {
|
||||
if (e.target === modal) {
|
||||
window.clearCustomProperties();
|
||||
if (themeBeforeBuilder) window.applyTheme(themeBeforeBuilder);
|
||||
}
|
||||
});
|
||||
})();
|
||||
376
status/js/theme.js
Normal file
376
status/js/theme.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// ========== THEME CONTROLLER ==========
|
||||
// User-created themes are stored server-side as individual JSON files.
|
||||
// localStorage caches them for instant load; server is source of truth.
|
||||
(function () {
|
||||
var THEME_KEY = 'theme';
|
||||
var USER_THEMES_KEY = 'user-themes';
|
||||
var LEGACY_CUSTOM_KEY = 'custom-theme';
|
||||
|
||||
var BUILTIN_THEMES = ['dark', 'light', 'blue', 'black', 'nord', 'dracula', 'solarized-dark', 'solarized-light', 'taxi', 'ocean'];
|
||||
var THEMES = BUILTIN_THEMES.slice();
|
||||
|
||||
var THEME_PROPS = [
|
||||
'bg', 'fg', 'muted', 'fg-muted', 'card-base', 'card-bg', 'border',
|
||||
'hover', 'card-hover', 'base',
|
||||
'ok-bg', 'ok-fg', 'bad-bg', 'bad-fg', 'dot-ok', 'dot-bad', 'uptime',
|
||||
'success', 'error', 'warning',
|
||||
'accent', 'accent-strong'
|
||||
];
|
||||
|
||||
// Base props the user picks in the builder (subset of THEME_PROPS)
|
||||
var BASE_PROPS = [
|
||||
'bg', 'fg', 'muted', 'card-base', 'card-bg', 'border',
|
||||
'ok-bg', 'ok-fg', 'bad-bg', 'bad-fg', 'dot-ok', 'dot-bad', 'uptime',
|
||||
'accent', 'accent-strong'
|
||||
];
|
||||
|
||||
// Props that are auto-derived if not explicitly set
|
||||
var DERIVED_PROPS = ['fg-muted', 'hover', 'card-hover', 'base', 'success', 'error', 'warning'];
|
||||
|
||||
var THEME_COLORS = {
|
||||
dark: { bg: '#0b0f1a', fg: '#e8ecf5', muted: '#9aa6bf', 'fg-muted': '#6b7a94', 'card-base': '#121826', 'card-bg': '#121826', border: '#263552', hover: '#1a2235', 'card-hover': '#161e2e', base: '#151c2b', 'ok-bg': '#0c2430', 'ok-fg': '#7ef2ff', 'bad-bg': '#2a121a', 'bad-fg': '#ff9aa3', 'dot-ok': '#35d1ff', 'dot-bad': '#ff5f7a', uptime: '#35d1ff', success: '#4caf50', error: '#e74c3c', warning: '#f39c12', accent: '#8FD6FF', 'accent-strong': '#1F7BFF' },
|
||||
light: { bg: '#f6f7fb', fg: '#0f1115', muted: '#5f6b7a', 'fg-muted': '#8993a4', 'card-base': '#ffffff', 'card-bg': '#ffffff', border: '#e2e7ef', hover: '#eef1f6', 'card-hover': '#f5f6fa', base: '#ebeef3', 'ok-bg': '#eafff1', 'ok-fg': '#0a7c3a', 'bad-bg': '#ffefef', 'bad-fg': '#b00020', 'dot-ok': '#0fb15a', 'dot-bad': '#d93b3b', uptime: '#0fb15a', success: '#0a7c3a', error: '#b00020', warning: '#d68a00', accent: '#4a90d9', 'accent-strong': '#2563eb', lightBg: true },
|
||||
blue: { bg: '#1908AC', fg: '#e8f1ff', muted: '#d6e2ff', 'fg-muted': '#9eafdb', 'card-base': '#0d1533', 'card-bg': '#0d1533', border: '#1c2d6a', hover: '#141f4a', 'card-hover': '#111a3e', base: '#0f1840', 'ok-bg': '#162040', 'ok-fg': '#edffff', 'bad-bg': '#0a0e24', 'bad-fg': '#ffb3c0', 'dot-ok': '#c7e5ff', 'dot-bad': '#ffd6dc', uptime: '#7ec8ff', success: '#7ec8ff', error: '#ffb3c0', warning: '#ffd080', accent: '#9cd4ff', 'accent-strong': '#6fb2ff' },
|
||||
black: { bg: '#0e0e0e', fg: '#f5f5f5', muted: '#999999', 'fg-muted': '#666666', 'card-base': '#1a1a1a', 'card-bg': '#1a1a1a', border: '#2e2e2e', hover: '#242424', 'card-hover': '#202020', base: '#161616', 'ok-bg': '#0f2a12', 'ok-fg': '#66ff7a', 'bad-bg': '#2a0f0f', 'bad-fg': '#ff6b6b', 'dot-ok': '#4caf50', 'dot-bad': '#ff4444', uptime: '#e0e0e0', success: '#4caf50', error: '#ff4444', warning: '#ff9800', accent: '#E63946', 'accent-strong': '#C62828' },
|
||||
nord: { bg: '#2e3440', fg: '#eceff4', muted: '#81a1c1', 'fg-muted': '#6882a0', 'card-base': '#3b4252', 'card-bg': '#3b4252', border: '#4c566a', hover: '#434c5e', 'card-hover': '#3f4858', base: '#353c4a', 'ok-bg': '#2d4f3e', 'ok-fg': '#a3be8c', 'bad-bg': '#4a2c2a', 'bad-fg': '#bf616a', 'dot-ok': '#a3be8c', 'dot-bad': '#bf616a', uptime: '#a3be8c', success: '#a3be8c', error: '#bf616a', warning: '#ebcb8b', accent: '#88c0d0', 'accent-strong': '#5e81ac' },
|
||||
dracula: { bg: '#282a36', fg: '#f8f8f2', muted: '#6272a4', 'fg-muted': '#515d85', 'card-base': '#44475a', 'card-bg': '#44475a', border: '#6272a4', hover: '#4e5170', 'card-hover': '#494c63', base: '#363848', 'ok-bg': '#1e3a2e', 'ok-fg': '#50fa7b', 'bad-bg': '#3d1a1a', 'bad-fg': '#ff5555', 'dot-ok': '#50fa7b', 'dot-bad': '#ff5555', uptime: '#50fa7b', success: '#50fa7b', error: '#ff5555', warning: '#f1fa8c', accent: '#bd93f9', 'accent-strong': '#8be9fd' },
|
||||
'solarized-dark': { bg: '#002b36', fg: '#839496', muted: '#586e75', 'fg-muted': '#4a5f65', 'card-base': '#073642', 'card-bg': '#073642', border: '#586e75', hover: '#0d4050', 'card-hover': '#0a3a48', base: '#053340', 'ok-bg': '#0d3d2c', 'ok-fg': '#859900', 'bad-bg': '#3d1a1a', 'bad-fg': '#dc322f', 'dot-ok': '#859900', 'dot-bad': '#dc322f', uptime: '#b5bd68', success: '#859900', error: '#dc322f', warning: '#b58900', accent: '#268bd2', 'accent-strong': '#2aa198' },
|
||||
'solarized-light':{ bg: '#fdf6e3', fg: '#657b83', muted: '#93a1a1', 'fg-muted': '#adb8b8', 'card-base': '#eee8d5', 'card-bg': '#eee8d5', border: '#93a1a1', hover: '#e6dfcb', 'card-hover': '#eae3cf', base: '#e8e1cd', 'ok-bg': '#e8f5e8', 'ok-fg': '#859900', 'bad-bg': '#fdf2f2', 'bad-fg': '#dc322f', 'dot-ok': '#859900', 'dot-bad': '#dc322f', uptime: '#859900', success: '#859900', error: '#dc322f', warning: '#b58900', accent: '#268bd2', 'accent-strong': '#2aa198', lightBg: true },
|
||||
taxi: { bg: '#f3d321', fg: '#0e0e00', muted: '#4a4a10', 'fg-muted': '#6b6b30', 'card-base': '#ffd700', 'card-bg': '#ffd700', border: '#b8a840', hover: '#ffe84d', 'card-hover': '#ffe033', base: '#f0d000', 'ok-bg': '#d4ffd9', 'ok-fg': '#0f2a0f', 'bad-bg': '#ffd4d4', 'bad-fg': '#2a0f0f', 'dot-ok': '#4caf50', 'dot-bad': '#ff4444', uptime: '#0e0e00', success: '#2e7d32', error: '#c62828', warning: '#e65100', accent: '#0e0e00', 'accent-strong': '#1a1a05', lightBg: true },
|
||||
ocean: { bg: '#2060b0', fg: '#faf5eb', muted: '#dcd2c0', 'fg-muted': '#b0a890', 'card-base': '#7a94ed', 'card-bg': '#7a94ed', border: '#deb67a', hover: '#8aa0f0', 'card-hover': '#8298e8', base: '#6888e0', 'ok-bg': '#4f5bb0', 'ok-fg': '#c7d7eb', 'bad-bg': '#f41a3a', 'bad-fg': '#6a1818', 'dot-ok': '#30a050', 'dot-bad': '#d04040', uptime: '#2d32f2', success: '#30a050', error: '#d04040', warning: '#e6a030', accent: '#1860a0', 'accent-strong': '#104080' },
|
||||
};
|
||||
|
||||
// ─── Color helpers ───────────────────────────────
|
||||
|
||||
function hexToRgb(hex) {
|
||||
if (!hex || typeof hex !== 'string') return { r: 0, g: 0, b: 0 };
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||
return {
|
||||
r: parseInt(hex.substr(0, 2), 16) || 0,
|
||||
g: parseInt(hex.substr(2, 2), 16) || 0,
|
||||
b: parseInt(hex.substr(4, 2), 16) || 0
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return '#' + [r, g, b].map(function (x) {
|
||||
var h = Math.max(0, Math.min(255, Math.round(x))).toString(16);
|
||||
return h.length === 1 ? '0' + h : h;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function blendColors(hex1, hex2, ratio) {
|
||||
var c1 = hexToRgb(hex1);
|
||||
var c2 = hexToRgb(hex2);
|
||||
return rgbToHex(
|
||||
c1.r + (c2.r - c1.r) * ratio,
|
||||
c1.g + (c2.g - c1.g) * ratio,
|
||||
c1.b + (c2.b - c1.b) * ratio
|
||||
);
|
||||
}
|
||||
|
||||
function hexLuminance(hex) {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||
var r = parseInt(hex.substr(0, 2), 16) / 255;
|
||||
var g = parseInt(hex.substr(2, 2), 16) / 255;
|
||||
var b = parseInt(hex.substr(4, 2), 16) / 255;
|
||||
r = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
|
||||
g = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
|
||||
b = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
// ─── Auto-derive extended colors from base palette ───────────
|
||||
|
||||
function deriveExtendedColors(colors) {
|
||||
var bg = colors.bg || '#0b0f1a';
|
||||
var fg = colors.fg || '#e8ecf5';
|
||||
var muted = colors.muted || '#9aa6bf';
|
||||
var cardBase = colors['card-base'] || colors.bg || '#121826';
|
||||
var dotOk = colors['dot-ok'] || '#4caf50';
|
||||
var dotBad = colors['dot-bad'] || '#e74c3c';
|
||||
var isLight = colors.lightBg || (bg && hexLuminance(bg) > 0.4);
|
||||
|
||||
var derived = {};
|
||||
|
||||
// hover: subtle shift of card-base toward fg (dark) or toward bg (light)
|
||||
derived.hover = isLight
|
||||
? blendColors(cardBase, bg, 0.35)
|
||||
: blendColors(cardBase, fg, 0.08);
|
||||
|
||||
// card-hover: midpoint between card-base and hover
|
||||
derived['card-hover'] = blendColors(cardBase, derived.hover, 0.5);
|
||||
|
||||
// base: subdued background between bg and card-base
|
||||
derived.base = blendColors(bg, cardBase, 0.6);
|
||||
|
||||
// fg-muted: dimmer than muted, blend toward bg
|
||||
derived['fg-muted'] = blendColors(muted, bg, 0.35);
|
||||
|
||||
// success: reuse dot-ok (the green of this theme)
|
||||
derived.success = dotOk;
|
||||
|
||||
// error: reuse dot-bad (the red of this theme)
|
||||
derived.error = dotBad;
|
||||
|
||||
// warning: warm amber, adjusted for contrast
|
||||
derived.warning = isLight ? '#d68a00' : '#f39c12';
|
||||
|
||||
return derived;
|
||||
}
|
||||
|
||||
// ─── Generate body background CSS for user themes ───────────
|
||||
|
||||
function generateBodyBackgroundCSS(slug, colors) {
|
||||
var isLight = colors.lightBg || (colors.bg && hexLuminance(colors.bg) > 0.4);
|
||||
var accent = colors.accent || colors['accent-strong'] || '#888888';
|
||||
var accentRgb = hexToRgb(accent);
|
||||
|
||||
if (isLight) {
|
||||
return ':root.' + slug + ' body {\n' +
|
||||
' background:\n' +
|
||||
' radial-gradient(1200px 800px at 10% -10%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .08), transparent 60%),\n' +
|
||||
' radial-gradient(1000px 700px at 110% 10%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .05), transparent 55%),\n' +
|
||||
' var(--bg);\n' +
|
||||
'}\n';
|
||||
} else {
|
||||
return ':root.' + slug + ' body {\n' +
|
||||
' background:\n' +
|
||||
' radial-gradient(1200px 900px at 8% -12%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .10), transparent 60%),\n' +
|
||||
' radial-gradient(1000px 700px at 110% -10%, rgba(' + accentRgb.r + ',' + accentRgb.g + ',' + accentRgb.b + ', .07), transparent 55%),\n' +
|
||||
' var(--bg);\n' +
|
||||
'}\n';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Generate button hover CSS for user themes ───────────
|
||||
|
||||
function generateButtonHoverCSS(slug, colors) {
|
||||
var isLight = colors.lightBg || (colors.bg && hexLuminance(colors.bg) > 0.4);
|
||||
|
||||
if (isLight) {
|
||||
return ':root.' + slug + ' button:hover {\n' +
|
||||
' background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);\n' +
|
||||
' border-color: rgba(0, 0, 0, .15);\n' +
|
||||
' box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);\n' +
|
||||
'}\n';
|
||||
} else {
|
||||
return ':root.' + slug + ' button:hover {\n' +
|
||||
' background: color-mix(in srgb, var(--accent) 18%, transparent);\n' +
|
||||
' border-color: color-mix(in srgb, var(--accent) 35%, var(--border));\n' +
|
||||
'}\n';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core theme functions ───────────────────────────────
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function clearCustomProperties() {
|
||||
THEME_PROPS.forEach(function (prop) {
|
||||
document.documentElement.style.removeProperty('--' + prop);
|
||||
});
|
||||
}
|
||||
|
||||
function slugify(name, currentSlug) {
|
||||
var slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
if (!slug) slug = 'custom';
|
||||
if (BUILTIN_THEMES.indexOf(slug) !== -1) slug = slug + '-custom';
|
||||
var cached = safeGetJSON(USER_THEMES_KEY, {});
|
||||
var base = slug;
|
||||
var counter = 2;
|
||||
while (cached[slug] && slug !== currentSlug) {
|
||||
slug = base + '-' + counter++;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
// Inject user themes from a themes object into DOM + runtime
|
||||
function injectUserThemeStyles(themesData) {
|
||||
var existing = document.getElementById('user-theme-styles');
|
||||
if (existing) existing.remove();
|
||||
|
||||
THEMES.length = BUILTIN_THEMES.length;
|
||||
Object.keys(THEME_COLORS).forEach(function (k) {
|
||||
if (BUILTIN_THEMES.indexOf(k) === -1) delete THEME_COLORS[k];
|
||||
});
|
||||
|
||||
// Use provided data, or fall back to localStorage cache
|
||||
var userThemes = themesData || safeGetJSON(USER_THEMES_KEY, {});
|
||||
var slugs = Object.keys(userThemes);
|
||||
|
||||
// Filter out any user themes that collide with built-in slugs
|
||||
slugs = slugs.filter(function (s) { return BUILTIN_THEMES.indexOf(s) === -1; });
|
||||
|
||||
if (!slugs.length) return;
|
||||
|
||||
var css = '';
|
||||
slugs.forEach(function (slug) {
|
||||
var theme = userThemes[slug];
|
||||
if (THEMES.indexOf(slug) === -1) THEMES.push(slug);
|
||||
|
||||
// Build color map from saved data
|
||||
var colorMap = {};
|
||||
THEME_PROPS.forEach(function (p) { if (theme[p]) colorMap[p] = theme[p]; });
|
||||
colorMap['card-bg'] = theme['card-base'] || theme.bg;
|
||||
if (theme.lightBg) colorMap.lightBg = true;
|
||||
|
||||
// Auto-derive any missing extended colors
|
||||
var derived = deriveExtendedColors(colorMap);
|
||||
DERIVED_PROPS.forEach(function (p) {
|
||||
if (!colorMap[p] && derived[p]) colorMap[p] = derived[p];
|
||||
});
|
||||
|
||||
THEME_COLORS[slug] = colorMap;
|
||||
|
||||
// Emit main variable block
|
||||
css += ':root.' + slug + ' {\n';
|
||||
THEME_PROPS.forEach(function (p) {
|
||||
if (colorMap[p]) css += ' --' + p + ': ' + colorMap[p] + ';\n';
|
||||
});
|
||||
css += '}\n';
|
||||
|
||||
// Emit body background gradient
|
||||
css += generateBodyBackgroundCSS(slug, colorMap);
|
||||
|
||||
// Emit button hover override
|
||||
css += generateButtonHoverCSS(slug, colorMap);
|
||||
});
|
||||
|
||||
var style = document.createElement('style');
|
||||
style.id = 'user-theme-styles';
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Fetch themes from server, update cache, re-inject if changed
|
||||
function syncThemesFromServer() {
|
||||
secureFetch('/api/v1/themes').then(function (r) { return r.json(); }).then(function (data) {
|
||||
if (!data.success || !data.themes) return;
|
||||
var serverThemes = data.themes;
|
||||
var cached = safeGetJSON(USER_THEMES_KEY, {});
|
||||
// Only update if different
|
||||
if (JSON.stringify(serverThemes) !== JSON.stringify(cached)) {
|
||||
safeSet(USER_THEMES_KEY, JSON.stringify(serverThemes));
|
||||
injectUserThemeStyles(serverThemes);
|
||||
// Re-apply current theme in case it was just synced
|
||||
var current = safeGet(THEME_KEY);
|
||||
if (current && THEMES.indexOf(current) !== -1) {
|
||||
applyTheme(current);
|
||||
}
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
// Migrate legacy custom-theme to server
|
||||
function migrateLegacyCustomTheme() {
|
||||
var legacy = safeGetJSON(LEGACY_CUSTOM_KEY);
|
||||
if (!legacy) return;
|
||||
|
||||
var name = legacy.name || 'Custom';
|
||||
var slug = slugify(name);
|
||||
|
||||
var themeData = { name: name };
|
||||
THEME_PROPS.forEach(function (p) {
|
||||
if (legacy[p]) themeData[p] = legacy[p];
|
||||
});
|
||||
|
||||
// Save to localStorage cache immediately
|
||||
var cached = safeGetJSON(USER_THEMES_KEY, {});
|
||||
cached[slug] = themeData;
|
||||
safeSet(USER_THEMES_KEY, JSON.stringify(cached));
|
||||
|
||||
// Update active theme reference
|
||||
if (safeGet(THEME_KEY) === 'custom') {
|
||||
safeSet(THEME_KEY, slug);
|
||||
}
|
||||
|
||||
safeRemove(LEGACY_CUSTOM_KEY);
|
||||
|
||||
// Also push to server
|
||||
var colors = {};
|
||||
THEME_PROPS.forEach(function (p) { if (themeData[p]) colors[p] = themeData[p]; });
|
||||
fetch('/api/v1/themes/' + slug, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, colors: colors })
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
document.documentElement.classList.add('theme-transitioning');
|
||||
|
||||
THEMES.forEach(function (t) {
|
||||
if (t !== 'dark') document.documentElement.classList.remove(t);
|
||||
});
|
||||
|
||||
clearCustomProperties();
|
||||
|
||||
if (mode !== 'dark') {
|
||||
document.documentElement.classList.add(mode);
|
||||
}
|
||||
|
||||
safeSet(THEME_KEY, mode);
|
||||
|
||||
var colors = THEME_COLORS[mode];
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta && colors) meta.setAttribute('content', colors.bg);
|
||||
|
||||
// Toggle light-bg class: explicit flag first, then auto-detect from luminance
|
||||
var isLight = colors && colors.lightBg;
|
||||
if (!isLight && colors && colors.bg) isLight = hexLuminance(colors.bg) > 0.4;
|
||||
if (isLight) {
|
||||
document.documentElement.classList.add('light-bg');
|
||||
} else {
|
||||
document.documentElement.classList.remove('light-bg');
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
document.documentElement.classList.remove('theme-transitioning');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
migrateLegacyCustomTheme();
|
||||
// Instant load from cache
|
||||
injectUserThemeStyles();
|
||||
|
||||
var savedTheme = safeGet(THEME_KEY);
|
||||
if (savedTheme === 'red') { savedTheme = 'black'; safeSet(THEME_KEY, 'black'); }
|
||||
if (savedTheme && savedTheme !== 'dark' && THEMES.indexOf(savedTheme) === -1) {
|
||||
savedTheme = null;
|
||||
}
|
||||
applyTheme(savedTheme || getSystemTheme());
|
||||
|
||||
// Sync from server in background (updates cache if server has newer data)
|
||||
syncThemesFromServer();
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
|
||||
if (!safeGet(THEME_KEY)) {
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
|
||||
// Expose API
|
||||
window.THEMES = THEMES;
|
||||
window.BUILTIN_THEMES = BUILTIN_THEMES;
|
||||
window.THEME_COLORS = THEME_COLORS;
|
||||
window.THEME_PROPS = THEME_PROPS;
|
||||
window.BASE_PROPS = BASE_PROPS;
|
||||
window.DERIVED_PROPS = DERIVED_PROPS;
|
||||
window.USER_THEMES_KEY = USER_THEMES_KEY;
|
||||
window.applyTheme = applyTheme;
|
||||
window.clearCustomProperties = clearCustomProperties;
|
||||
window.injectUserThemeStyles = injectUserThemeStyles;
|
||||
window.syncThemesFromServer = syncThemesFromServer;
|
||||
window.slugifyThemeName = slugify;
|
||||
window.getActiveTheme = function () { return safeGet(THEME_KEY) || getSystemTheme(); };
|
||||
window.deriveExtendedColors = deriveExtendedColors;
|
||||
window.hexToRgb = hexToRgb;
|
||||
window.rgbToHex = rgbToHex;
|
||||
window.blendColors = blendColors;
|
||||
})();
|
||||
538
status/js/tooltip-definitions.js
Normal file
538
status/js/tooltip-definitions.js
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Tooltip Definitions
|
||||
* Defines all tooltip content, positioning, and behavior for the onboarding system
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Validate a tooltip definition
|
||||
* @param {Object} tooltip - The tooltip definition to validate
|
||||
* @returns {Object} { valid: boolean, errors: string[] }
|
||||
*/
|
||||
function validateTooltipDefinition(tooltip) {
|
||||
const errors = [];
|
||||
|
||||
// Required fields
|
||||
if (!tooltip.id || typeof tooltip.id !== 'string') {
|
||||
errors.push('Tooltip must have a valid string id');
|
||||
}
|
||||
|
||||
if (!tooltip.element) {
|
||||
errors.push('Tooltip must have an element selector or HTMLElement');
|
||||
}
|
||||
|
||||
if (!tooltip.popover || typeof tooltip.popover !== 'object') {
|
||||
errors.push('Tooltip must have a popover object');
|
||||
} else {
|
||||
// Validate popover fields
|
||||
if (!tooltip.popover.title || typeof tooltip.popover.title !== 'string') {
|
||||
errors.push('Tooltip popover must have a valid string title');
|
||||
}
|
||||
|
||||
if (!tooltip.popover.description || typeof tooltip.popover.description !== 'string') {
|
||||
errors.push('Tooltip popover must have a valid string description');
|
||||
}
|
||||
|
||||
// Validate position if provided
|
||||
if (tooltip.popover.position) {
|
||||
const validPositions = ['top', 'bottom', 'left', 'right', 'center'];
|
||||
if (!validPositions.includes(tooltip.popover.position)) {
|
||||
errors.push(`Invalid position: ${tooltip.popover.position}. Must be one of: ${validPositions.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate align if provided
|
||||
if (tooltip.popover.align) {
|
||||
const validAligns = ['start', 'center', 'end'];
|
||||
if (!validAligns.includes(tooltip.popover.align)) {
|
||||
errors.push(`Invalid align: ${tooltip.popover.align}. Must be one of: ${validAligns.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate showButtons if provided
|
||||
if (tooltip.popover.showButtons && !Array.isArray(tooltip.popover.showButtons)) {
|
||||
errors.push('showButtons must be an array');
|
||||
}
|
||||
|
||||
// Validate callbacks if provided
|
||||
const callbacks = ['onNext', 'onPrevious', 'onClose', 'onSetupNow', 'onLater'];
|
||||
callbacks.forEach(callback => {
|
||||
if (tooltip.popover[callback] && typeof tooltip.popover[callback] !== 'function') {
|
||||
errors.push(`${callback} must be a function`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate condition if provided
|
||||
if (tooltip.condition && typeof tooltip.condition !== 'function') {
|
||||
errors.push('condition must be a function');
|
||||
}
|
||||
|
||||
// Validate priority if provided
|
||||
if (tooltip.priority !== undefined && typeof tooltip.priority !== 'number') {
|
||||
errors.push('priority must be a number');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of tooltip definitions
|
||||
* @param {Array} tooltips - Array of tooltip definitions
|
||||
* @returns {Object} { valid: boolean, errors: Object[] }
|
||||
*/
|
||||
function validateTooltipDefinitions(tooltips) {
|
||||
if (!Array.isArray(tooltips)) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{ tooltip: null, errors: ['tooltips must be an array'] }]
|
||||
};
|
||||
}
|
||||
|
||||
const allErrors = [];
|
||||
const ids = new Set();
|
||||
|
||||
tooltips.forEach((tooltip, index) => {
|
||||
const validation = validateTooltipDefinition(tooltip);
|
||||
|
||||
if (!validation.valid) {
|
||||
allErrors.push({
|
||||
tooltip: tooltip.id || `index ${index}`,
|
||||
errors: validation.errors
|
||||
});
|
||||
}
|
||||
|
||||
// Check for duplicate IDs
|
||||
if (tooltip.id) {
|
||||
if (ids.has(tooltip.id)) {
|
||||
allErrors.push({
|
||||
tooltip: tooltip.id,
|
||||
errors: [`Duplicate tooltip ID: ${tooltip.id}`]
|
||||
});
|
||||
}
|
||||
ids.add(tooltip.id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
valid: allErrors.length === 0,
|
||||
errors: allErrors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handler for tooltip system
|
||||
*/
|
||||
class TooltipError extends Error {
|
||||
constructor(message, tooltipId = null) {
|
||||
super(message);
|
||||
this.name = 'TooltipError';
|
||||
this.tooltipId = tooltipId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tooltip definition errors
|
||||
* @param {Object} validation - Validation result
|
||||
* @throws {TooltipError} If validation fails
|
||||
*/
|
||||
function handleValidationErrors(validation) {
|
||||
if (!validation.valid) {
|
||||
const errorMessages = validation.errors.map(e =>
|
||||
`${e.tooltip}: ${e.errors.join(', ')}`
|
||||
).join('\n');
|
||||
|
||||
console.error('[TooltipDefinitions] Validation errors:', errorMessages);
|
||||
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export to global scope
|
||||
window.TooltipValidation = {
|
||||
validateTooltipDefinition,
|
||||
validateTooltipDefinitions,
|
||||
handleValidationErrors,
|
||||
TooltipError
|
||||
};
|
||||
|
||||
console.log('[TooltipDefinitions] Validation module loaded');
|
||||
|
||||
})(window);
|
||||
|
||||
|
||||
/**
|
||||
* Tooltip Definitions Array
|
||||
* Defines all tooltips for the onboarding tour
|
||||
*/
|
||||
const TOOLTIP_DEFINITIONS = [
|
||||
// 1. Welcome
|
||||
{
|
||||
id: 'welcome',
|
||||
element: '#brand',
|
||||
popover: {
|
||||
title: 'Welcome to DashCaddy!',
|
||||
description: `
|
||||
<p>Your unified control panel for <strong>Docker</strong>, <strong>Caddy</strong>, and <strong>DNS</strong> — all in one place.</p>
|
||||
<p>This tour will walk you through every section so you know exactly where everything is.</p>
|
||||
<p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.7;">You can click your logo anytime to customize it.</p>
|
||||
`,
|
||||
position: 'bottom',
|
||||
align: 'start',
|
||||
showButtons: ['next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 1
|
||||
},
|
||||
|
||||
// 2. Infrastructure row
|
||||
{
|
||||
id: 'infrastructure',
|
||||
element: '.top',
|
||||
popover: {
|
||||
title: 'Infrastructure Overview',
|
||||
description: `
|
||||
<p>This top row shows the backbone of your setup:</p>
|
||||
<ul>
|
||||
<li><strong>DNS1 / DNS2 / DNS3</strong> — your Technitium DNS servers</li>
|
||||
<li><strong>Internet</strong> — live connectivity check with packet indicators</li>
|
||||
<li><strong>Auth</strong> — TOTP authentication status</li>
|
||||
<li><strong>DashCA</strong> — your certificate authority for your custom domain</li>
|
||||
</ul>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">These core services are always pinned here, separate from your app grid.</p>
|
||||
`,
|
||||
position: 'bottom',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 2
|
||||
},
|
||||
|
||||
// 3. Service cards
|
||||
{
|
||||
id: 'service-cards',
|
||||
element: '#cards',
|
||||
popover: {
|
||||
title: 'Your App Grid',
|
||||
description: `
|
||||
<p>Every app you deploy appears here as a card. Each one shows:</p>
|
||||
<ul>
|
||||
<li><strong>Status dot</strong> — green = online, red = offline (pulses when down)</li>
|
||||
<li><strong>ON/OFF badge</strong> — current state at a glance</li>
|
||||
<li><strong>Response time</strong> — how fast the service responds (color-coded)</li>
|
||||
<li><strong>Uptime bar</strong> — historical uptime percentage</li>
|
||||
</ul>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">Hover over a card to see action buttons: Logs, Restart, Update, Settings, and Open.</p>
|
||||
`,
|
||||
position: 'top',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 3
|
||||
},
|
||||
|
||||
// 4. App Selector
|
||||
{
|
||||
id: 'app-selector',
|
||||
element: '#add-service-btn',
|
||||
popover: {
|
||||
title: 'App Selector — Deploy in One Click',
|
||||
description: `
|
||||
<p>Browse <strong>50+ self-hosted apps</strong> organized by category:</p>
|
||||
<ul>
|
||||
<li>Media (Plex, Jellyfin, Emby, Overseerr)</li>
|
||||
<li>Downloads (*arr stack, qBittorrent, Transmission)</li>
|
||||
<li>Productivity (Nextcloud, Vaultwarden, Gitea)</li>
|
||||
<li>Monitoring (Uptime Kuma, Grafana, Prometheus)</li>
|
||||
</ul>
|
||||
<p>Each app deploys with Docker, Caddy reverse proxy, and DNS — fully configured automatically.</p>
|
||||
<p style="margin-top: 6px; padding: 6px 10px; border-radius: 6px; background: rgba(241,196,15,0.12); border: 1px solid rgba(241,196,15,0.25); font-size: 0.85rem;">
|
||||
<strong style="color: #f1c40f;">★ Premium:</strong> <strong>Recipes</strong> let you deploy entire stacks (e.g., full media server) in one click with pre-wired configs.
|
||||
</p>
|
||||
`,
|
||||
position: 'top',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 4,
|
||||
condition: () => document.getElementById('add-service-btn') !== null
|
||||
},
|
||||
|
||||
// 5. Smart Arr Connect
|
||||
{
|
||||
id: 'smart-arr',
|
||||
element: '#arr-setup-btn',
|
||||
popover: {
|
||||
title: 'Smart Arr Connect',
|
||||
description: `
|
||||
<p>Automatically wire up your entire media stack:</p>
|
||||
<ul>
|
||||
<li>Detects running Arr services (Radarr, Sonarr, Prowlarr, etc.)</li>
|
||||
<li>Connects them together with the right API keys</li>
|
||||
<li>Links Plex/Jellyfin to Overseerr for request management</li>
|
||||
</ul>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">No manual API key copying — DashCaddy handles it all.</p>
|
||||
`,
|
||||
position: 'top',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 5,
|
||||
condition: () => document.getElementById('arr-setup-btn') !== null
|
||||
},
|
||||
|
||||
// 6. Add App Manually (toolbar)
|
||||
{
|
||||
id: 'add-manual',
|
||||
element: '#add-service',
|
||||
popover: {
|
||||
title: 'Add App Manually',
|
||||
description: `
|
||||
<p>Already have a service running? Add it to your dashboard manually.</p>
|
||||
<p>You can add:</p>
|
||||
<ul>
|
||||
<li><strong>Docker containers</strong> — auto-detects running containers</li>
|
||||
<li><strong>External services</strong> — any URL you want to monitor</li>
|
||||
<li><strong>Test services</strong> — try the UI without deploying anything</li>
|
||||
</ul>
|
||||
`,
|
||||
position: 'bottom',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 6
|
||||
},
|
||||
|
||||
// 7. Theme
|
||||
{
|
||||
id: 'theme-selector',
|
||||
element: '#theme',
|
||||
popover: {
|
||||
title: 'Themes',
|
||||
description: `
|
||||
<p>Switch between <strong>7 built-in themes</strong>:</p>
|
||||
<p>Dark, Light, Blue, Nord, Dracula, Solarized Dark, and Solarized Light.</p>
|
||||
<p>Your preference is saved automatically. You can also build custom themes with the Theme Builder (in Admin → settings).</p>
|
||||
`,
|
||||
position: 'bottom',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 7
|
||||
},
|
||||
|
||||
// 8. Toolbar: Status section
|
||||
{
|
||||
id: 'toolbar-status',
|
||||
element: '.tools-section[data-section="status"]',
|
||||
popover: {
|
||||
title: 'Status Tools',
|
||||
description: `
|
||||
<p>Click <strong>Status</strong> to expand these tools:</p>
|
||||
<ul>
|
||||
<li><strong>Monitor</strong> — live CPU, memory, and disk usage for every container</li>
|
||||
<li><strong>Health</strong> — service health dashboard with uptime history and alerts</li>
|
||||
<li><strong>Updates</strong> — check for and apply Docker image updates</li>
|
||||
</ul>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">These sections remember whether you left them open or closed.</p>
|
||||
`,
|
||||
position: 'bottom',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 8
|
||||
},
|
||||
|
||||
// 9. Toolbar: Tools section
|
||||
{
|
||||
id: 'toolbar-tools',
|
||||
element: '.tools-section[data-section="tools"]',
|
||||
popover: {
|
||||
title: 'Operational Tools',
|
||||
description: `
|
||||
<p>Click <strong>Tools</strong> for day-to-day operations:</p>
|
||||
<ul>
|
||||
<li><strong>Logs</strong> — view error logs from your API server and services</li>
|
||||
<li><strong>Alerts</strong> — configure notification rules (email, webhook, etc.)</li>
|
||||
<li><strong>Audit</strong> — full audit trail of every action taken in DashCaddy</li>
|
||||
</ul>
|
||||
`,
|
||||
position: 'bottom',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 9
|
||||
},
|
||||
|
||||
// 10. Toolbar: Admin section
|
||||
{
|
||||
id: 'toolbar-admin',
|
||||
element: '.tools-section[data-section="admin"]',
|
||||
popover: {
|
||||
title: 'Admin & Configuration',
|
||||
description: `
|
||||
<p>Click <strong>Admin</strong> for setup and maintenance:</p>
|
||||
<ul>
|
||||
<li><strong>Tokens</strong> — manage API keys for DNS and other integrations</li>
|
||||
<li><strong>Export / Import</strong> — backup and restore your dashboard configuration</li>
|
||||
<li><strong>Backup</strong> — full system backup and restore</li>
|
||||
<li><strong>License</strong> — activate your license code to unlock premium features</li>
|
||||
<li><strong>API</strong> — interactive API documentation (125+ endpoints)</li>
|
||||
</ul>
|
||||
`,
|
||||
position: 'bottom',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 10
|
||||
},
|
||||
|
||||
// 11. Weather widget
|
||||
{
|
||||
id: 'weather',
|
||||
element: '#weather-widget',
|
||||
popover: {
|
||||
title: 'Weather Widget',
|
||||
description: `
|
||||
<p>Shows current conditions for your location. Click the <strong>gear icon</strong> to configure your city or switch temperature units.</p>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">Uses Open-Meteo — no API key required.</p>
|
||||
`,
|
||||
position: 'bottom',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 11,
|
||||
condition: () => document.getElementById('weather-widget') !== null
|
||||
},
|
||||
|
||||
// 12. Reload Caddy
|
||||
{
|
||||
id: 'reload-caddy',
|
||||
element: '#reload-caddy-top',
|
||||
popover: {
|
||||
title: 'Reload Caddy',
|
||||
description: `
|
||||
<p>After changing Caddy configuration (adding reverse proxy rules, SSL settings, etc.), click here to apply the changes live.</p>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">This is a graceful reload — existing connections are not dropped.</p>
|
||||
`,
|
||||
position: 'bottom',
|
||||
align: 'end',
|
||||
showButtons: ['previous', 'next'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 12,
|
||||
condition: () => document.getElementById('reload-caddy-top') !== null
|
||||
},
|
||||
|
||||
// 13. Finish
|
||||
{
|
||||
id: 'tour-complete',
|
||||
element: '#brand',
|
||||
popover: {
|
||||
title: 'You\'re All Set!',
|
||||
description: `
|
||||
<p>That covers the essentials. A few tips to get the most out of DashCaddy:</p>
|
||||
<ul>
|
||||
<li><strong>Click any card</strong> to open that service directly</li>
|
||||
<li><strong>Keyboard shortcuts</strong> — press <code>?</code> anytime to see them all</li>
|
||||
<li><strong>DashCA</strong> — visit your CA page to install the root certificate on any device</li>
|
||||
</ul>
|
||||
<div style="margin-top: 10px; padding: 10px 12px; border-radius: 8px; background: linear-gradient(135deg, rgba(241,196,15,0.1), rgba(243,156,18,0.08)); border: 1px solid rgba(241,196,15,0.25);">
|
||||
<p style="margin: 0 0 6px; font-weight: 600; color: #f1c40f;">★ Unlock Premium</p>
|
||||
<p style="margin: 0; font-size: 0.85rem;">Premium adds powerful features for serious homelabbers:</p>
|
||||
<ul style="margin: 6px 0 0; font-size: 0.85rem;">
|
||||
<li><strong>Auto-Login SSO</strong> — sign into every app automatically, no more password juggling</li>
|
||||
<li><strong>Recipes</strong> — deploy full stacks (media server, dev environment) in one click</li>
|
||||
<li><strong>Docker Swarm</strong> — orchestrate multi-node clusters from your dashboard</li>
|
||||
</ul>
|
||||
<p style="margin: 8px 0 0; font-size: 0.82rem; opacity: 0.8;">Activate in <strong>Admin → License</strong></p>
|
||||
</div>
|
||||
<p style="margin-top: 10px; font-size: 0.85rem; opacity: 0.7;">You can restart this tour anytime from <strong>Admin → Help Tour</strong>.</p>
|
||||
`,
|
||||
position: 'bottom',
|
||||
align: 'start',
|
||||
showButtons: ['previous', 'close'],
|
||||
showProgress: true
|
||||
},
|
||||
priority: 13
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Get tooltip definitions
|
||||
* @returns {Array} Array of tooltip definitions
|
||||
*/
|
||||
function getTooltipDefinitions() {
|
||||
return TOOLTIP_DEFINITIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific tooltip by ID
|
||||
* @param {string} id - Tooltip ID
|
||||
* @returns {Object|null} Tooltip definition or null if not found
|
||||
*/
|
||||
function getTooltipById(id) {
|
||||
return TOOLTIP_DEFINITIONS.find(t => t.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltips filtered by condition
|
||||
* @returns {Array} Array of tooltips that pass their condition check
|
||||
*/
|
||||
function getActiveTooltips() {
|
||||
return TOOLTIP_DEFINITIONS.filter(tooltip => {
|
||||
if (tooltip.condition && typeof tooltip.condition === 'function') {
|
||||
try {
|
||||
return tooltip.condition();
|
||||
} catch (error) {
|
||||
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltips sorted by priority
|
||||
* @returns {Array} Array of tooltips sorted by priority (ascending)
|
||||
*/
|
||||
function getSortedTooltips() {
|
||||
const tooltips = getActiveTooltips();
|
||||
return tooltips.sort((a, b) => {
|
||||
const priorityA = a.priority || 999;
|
||||
const priorityB = b.priority || 999;
|
||||
return priorityA - priorityB;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tooltips marked as new features
|
||||
* @returns {Array} Array of tooltips marked with isNewFeature flag
|
||||
*/
|
||||
function getNewFeatureTooltips() {
|
||||
const tooltips = getActiveTooltips();
|
||||
return tooltips.filter(tooltip => tooltip.isNewFeature === true)
|
||||
.sort((a, b) => {
|
||||
const priorityA = a.priority || 999;
|
||||
const priorityB = b.priority || 999;
|
||||
return priorityA - priorityB;
|
||||
});
|
||||
}
|
||||
|
||||
// Export to global scope
|
||||
window.TooltipDefinitions = {
|
||||
TOOLTIP_DEFINITIONS,
|
||||
getTooltipDefinitions,
|
||||
getTooltipById,
|
||||
getActiveTooltips,
|
||||
getSortedTooltips,
|
||||
getNewFeatureTooltips
|
||||
};
|
||||
|
||||
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
|
||||
|
||||
115
status/js/totp-auth.js
Normal file
115
status/js/totp-auth.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ===== TOTP AUTHENTICATION GATE =====
|
||||
(function() {
|
||||
function updateTotpLogo() {
|
||||
const card = document.querySelector('.totp-card');
|
||||
if (!card) return;
|
||||
const bg = getComputedStyle(card).backgroundColor;
|
||||
const m = bg.match(/\d+/g);
|
||||
if (!m) return;
|
||||
const lum = (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255;
|
||||
const dark = card.querySelector('.totp-logo-dark');
|
||||
const light = card.querySelector('.totp-logo-light');
|
||||
if (dark) dark.style.display = lum > 0.5 ? 'none' : '';
|
||||
if (light) light.style.display = lum > 0.5 ? '' : 'none';
|
||||
}
|
||||
|
||||
function showTotpOverlay() {
|
||||
const overlay = document.getElementById('totp-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('show');
|
||||
setTimeout(updateTotpLogo, 50);
|
||||
const firstInput = overlay.querySelector('.totp-digits input');
|
||||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function hideTotpOverlay() {
|
||||
const overlay = document.getElementById('totp-overlay');
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
}
|
||||
|
||||
// Setup digit input UX
|
||||
const container = document.getElementById('totp-digits');
|
||||
if (container) {
|
||||
const inputs = container.querySelectorAll('input');
|
||||
inputs.forEach((input, idx) => {
|
||||
input.addEventListener('input', (e) => {
|
||||
const val = e.target.value.replace(/\D/g, '');
|
||||
e.target.value = val.slice(0, 1);
|
||||
if (val && idx < inputs.length - 1) inputs[idx + 1].focus();
|
||||
const code = Array.from(inputs).map(i => i.value).join('');
|
||||
if (code.length === 6) submitTotpCode(code);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !e.target.value && idx > 0) {
|
||||
inputs[idx - 1].focus();
|
||||
inputs[idx - 1].value = '';
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const pasted = (e.clipboardData.getData('text') || '').replace(/\D/g, '');
|
||||
if (pasted.length >= 6) {
|
||||
inputs.forEach((inp, i) => { inp.value = pasted[i] || ''; });
|
||||
inputs[5].focus();
|
||||
submitTotpCode(pasted.slice(0, 6));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function submitTotpCode(code) {
|
||||
const errorEl = document.getElementById('totp-error');
|
||||
errorEl.textContent = 'Verifying...';
|
||||
errorEl.className = 'totp-error verifying';
|
||||
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/totp/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
errorEl.textContent = '';
|
||||
hideTotpOverlay();
|
||||
// Check if redirected here from another service
|
||||
const redirect = safeSessionGet('totp_redirect');
|
||||
if (redirect) {
|
||||
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||
window.location.href = redirect;
|
||||
return;
|
||||
}
|
||||
// Initialize dashboard
|
||||
if (typeof window.initializeDashboard === 'function') {
|
||||
window.initializeDashboard();
|
||||
}
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Invalid code';
|
||||
errorEl.className = 'totp-error';
|
||||
const inputs = document.querySelectorAll('#totp-digits input');
|
||||
inputs.forEach(i => { i.value = ''; });
|
||||
inputs[0]?.focus();
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Connection error';
|
||||
errorEl.className = 'totp-error';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ?auth=required redirect from Caddy SSO
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('auth') === 'required') {
|
||||
const returnUrl = urlParams.get('return');
|
||||
if (returnUrl && returnUrl.includes(SITE.tld)) {
|
||||
safeSessionSet('totp_redirect', returnUrl);
|
||||
}
|
||||
// Clean URL
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
|
||||
window._showTotpOverlay = showTotpOverlay;
|
||||
})();
|
||||
322
status/js/totp-settings.js
Normal file
322
status/js/totp-settings.js
Normal file
@@ -0,0 +1,322 @@
|
||||
// ===== TOTP SETTINGS =====
|
||||
(function() {
|
||||
injectModal('totp-settings-modal', `<div id="totp-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
|
||||
|
||||
<!-- Status Banner -->
|
||||
<div id="totp-status-banner" style="margin-bottom: 16px; padding: 12px 16px; border-radius: 8px; border: 1px solid var(--border); display: flex; align-items: center; gap: 8px;">
|
||||
<span id="totp-status-dot" class="status-dot"></span>
|
||||
<span id="totp-status-text" style="font-size: 0.9rem;">TOTP is not configured</span>
|
||||
</div>
|
||||
|
||||
<!-- Setup Button (not configured state) -->
|
||||
<div id="totp-setup-section">
|
||||
<button id="totp-setup-btn" style="width: 100%; padding: 12px; background: var(--accent, #8FD6FF); color: #0b0f1a; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.95rem;">
|
||||
Generate New Secret
|
||||
</button>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin: 10px 0 0;">
|
||||
<div class="divider-line"></div>
|
||||
<span class="text-tiny-muted">or</span>
|
||||
<div class="divider-line"></div>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<label class="text-hint">Import an existing secret key:</label>
|
||||
<div style="display: flex; gap: 8px; margin-top: 6px;">
|
||||
<input type="text" id="totp-import-key" placeholder="Paste your Base32 key" autocomplete="off" spellcheck="false"
|
||||
style="flex: 1; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; font-family: monospace; letter-spacing: 1px; text-transform: uppercase;" />
|
||||
<button id="totp-import-btn" style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret Display (setup flow) -->
|
||||
<div id="totp-qr-section" style="display: none;">
|
||||
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">Copy this key into your authenticator app:</p>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
|
||||
<code id="totp-manual-key" style="flex: 1; display: block; padding: 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 2px; word-break: break-all; user-select: all; color: var(--fg);"></code>
|
||||
<button id="totp-copy-key" style="padding: 10px 14px; background: var(--card-base); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 1rem; white-space: nowrap; color: var(--fg);" title="Copy to clipboard">📋</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (secondary - for mobile apps) -->
|
||||
<details class="mb-16">
|
||||
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
|
||||
<div style="text-align: center; margin-top: 8px;">
|
||||
<img id="totp-qr-image" style="width: 180px; height: 180px; border-radius: 8px;" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Verify First Code -->
|
||||
<div style="border-top: 1px solid var(--border); padding-top: 16px;">
|
||||
<label class="font-bold-sm">Enter code to confirm setup:</label>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
||||
<input type="text" id="totp-setup-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000"
|
||||
style="flex: 1; text-align: center; font-size: 1.3rem; padding: 10px; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 2px solid var(--border); border-radius: 8px; outline: none;" />
|
||||
<button id="totp-confirm-setup" style="padding: 10px 20px; background: var(--accent, #8FD6FF); color: #0b0f1a; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
<div id="totp-setup-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Duration (active state) -->
|
||||
<div id="totp-duration-section" style="display: none; margin-top: 12px;">
|
||||
<label class="font-bold-sm">Session Duration:</label>
|
||||
<select id="totp-duration-select" style="width: 100%; padding: 10px; margin-top: 6px; background: var(--bg, #0b0f1a); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; font-size: 0.9rem; cursor: pointer;">
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="30m">30 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="2h">2 hours</option>
|
||||
<option value="4h">4 hours</option>
|
||||
<option value="8h">8 hours</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="never">Never (disable TOTP)</option>
|
||||
</select>
|
||||
<p style="font-size: 0.75rem; color: var(--muted); margin: 4px 0 0;">How long before you need to re-enter your code</p>
|
||||
</div>
|
||||
|
||||
<!-- Disable Button (active state) -->
|
||||
<div id="totp-disable-section" style="display: none; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border);">
|
||||
<button id="totp-disable-btn" style="width: 100%; padding: 10px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 8px; cursor: pointer; font-size: 0.9rem;">
|
||||
Disable TOTP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Close -->
|
||||
<div style="margin-top: 16px; text-align: right;">
|
||||
<button id="totp-modal-close" style="padding: 8px 20px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
async function loadTotpSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/totp/config');
|
||||
const data = await res.json();
|
||||
if (!data.success) return;
|
||||
|
||||
const { enabled, sessionDuration, isSetUp } = data.config;
|
||||
const statusDot = document.getElementById('totp-status-dot');
|
||||
const statusText = document.getElementById('totp-status-text');
|
||||
const statusBanner = document.getElementById('totp-status-banner');
|
||||
const setupSection = document.getElementById('totp-setup-section');
|
||||
const qrSection = document.getElementById('totp-qr-section');
|
||||
const durationSection = document.getElementById('totp-duration-section');
|
||||
const disableSection = document.getElementById('totp-disable-section');
|
||||
|
||||
if (enabled && isSetUp) {
|
||||
statusDot.style.background = 'var(--ok-fg, #7ef2ff)';
|
||||
statusBanner.style.borderColor = 'var(--ok-fg, #7ef2ff)';
|
||||
statusBanner.style.background = 'color-mix(in srgb, var(--ok-fg) 8%, transparent)';
|
||||
statusText.textContent = 'TOTP is active';
|
||||
statusText.style.color = 'var(--ok-fg, #7ef2ff)';
|
||||
setupSection.style.display = 'none';
|
||||
qrSection.style.display = 'none';
|
||||
durationSection.style.display = 'block';
|
||||
disableSection.style.display = 'block';
|
||||
document.getElementById('totp-duration-select').value = sessionDuration;
|
||||
} else {
|
||||
statusDot.style.background = 'var(--muted)';
|
||||
statusBanner.style.borderColor = 'var(--border)';
|
||||
statusBanner.style.background = 'transparent';
|
||||
statusText.textContent = 'TOTP is not configured';
|
||||
statusText.style.color = 'var(--muted)';
|
||||
setupSection.style.display = 'block';
|
||||
qrSection.style.display = 'none';
|
||||
durationSection.style.display = 'none';
|
||||
disableSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update the Auth card in the top row
|
||||
updateAuthCard(enabled && isSetUp, sessionDuration);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load TOTP settings:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Duration label helper
|
||||
const DURATION_LABELS = {
|
||||
'15m': '15 min', '30m': '30 min', '1h': '1 hour', '2h': '2 hours',
|
||||
'4h': '4 hours', '8h': '8 hours', '12h': '12 hours', '24h': '24 hours', 'never': 'Disabled'
|
||||
};
|
||||
|
||||
function updateAuthCard(active, duration) {
|
||||
const card = document.getElementById('auth-card');
|
||||
const pill = document.getElementById('auth-pill');
|
||||
const dot = document.getElementById('auth-dot');
|
||||
const statusText = document.getElementById('auth-status-text');
|
||||
if (!card) return;
|
||||
|
||||
if (active) {
|
||||
card.setAttribute('data-status', 'on');
|
||||
pill.className = 'badge on';
|
||||
pill.textContent = 'YES';
|
||||
dot.className = 'dot ok at-bl';
|
||||
statusText.textContent = 'Session: ' + (DURATION_LABELS[duration] || duration);
|
||||
} else {
|
||||
card.setAttribute('data-status', 'off');
|
||||
pill.className = 'badge off';
|
||||
pill.textContent = 'NO';
|
||||
dot.className = 'dot bad at-bl';
|
||||
statusText.textContent = 'Not configured';
|
||||
}
|
||||
}
|
||||
|
||||
// Setup button (generate new secret)
|
||||
document.getElementById('totp-setup-btn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/totp/setup', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('totp-qr-image').src = data.qrCode;
|
||||
document.getElementById('totp-manual-key').textContent = data.manualKey;
|
||||
document.getElementById('totp-setup-section').style.display = 'none';
|
||||
document.getElementById('totp-qr-section').style.display = 'block';
|
||||
document.getElementById('totp-setup-code').value = '';
|
||||
document.getElementById('totp-setup-error').textContent = '';
|
||||
document.getElementById('totp-setup-code').focus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('TOTP setup failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Import existing secret button
|
||||
document.getElementById('totp-import-btn')?.addEventListener('click', async () => {
|
||||
const secret = document.getElementById('totp-import-key').value.trim();
|
||||
if (!secret) return;
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/totp/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('totp-qr-image').src = data.qrCode;
|
||||
document.getElementById('totp-manual-key').textContent = data.manualKey;
|
||||
document.getElementById('totp-setup-section').style.display = 'none';
|
||||
document.getElementById('totp-qr-section').style.display = 'block';
|
||||
document.getElementById('totp-setup-code').value = '';
|
||||
document.getElementById('totp-setup-error').textContent = '';
|
||||
document.getElementById('totp-setup-code').focus();
|
||||
} else {
|
||||
document.getElementById('totp-import-key').style.borderColor = 'var(--bad-fg)';
|
||||
setTimeout(() => { document.getElementById('totp-import-key').style.borderColor = ''; }, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('TOTP import failed:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Copy key button
|
||||
document.getElementById('totp-copy-key')?.addEventListener('click', () => {
|
||||
const key = document.getElementById('totp-manual-key').textContent;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
const btn = document.getElementById('totp-copy-key');
|
||||
btn.textContent = '✅';
|
||||
setTimeout(() => { btn.textContent = '📋'; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm setup
|
||||
document.getElementById('totp-confirm-setup')?.addEventListener('click', async () => {
|
||||
const code = document.getElementById('totp-setup-code').value;
|
||||
const errorEl = document.getElementById('totp-setup-error');
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
errorEl.textContent = 'Enter a 6-digit code';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/totp/verify-setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
errorEl.textContent = '';
|
||||
loadTotpSettings(); // Updates both modal and card
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Invalid code';
|
||||
}
|
||||
} catch (e) {
|
||||
errorEl.textContent = 'Connection error';
|
||||
}
|
||||
});
|
||||
|
||||
// Allow Enter key on setup code input
|
||||
document.getElementById('totp-setup-code')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') document.getElementById('totp-confirm-setup')?.click();
|
||||
});
|
||||
|
||||
// Duration change
|
||||
document.getElementById('totp-duration-select')?.addEventListener('change', async (e) => {
|
||||
try {
|
||||
await secureFetch('/api/v1/totp/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionDuration: e.target.value })
|
||||
});
|
||||
loadTotpSettings(); // Refresh modal + card (handles "never" disabling TOTP)
|
||||
} catch (err) {
|
||||
console.error('Failed to update session duration:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable TOTP
|
||||
document.getElementById('totp-disable-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm('Disable TOTP authentication? All services will be accessible without a code.')) return;
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/totp/disable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) loadTotpSettings();
|
||||
} catch (e) {
|
||||
console.error('Failed to disable TOTP:', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Open settings modal from Auth card
|
||||
document.getElementById('auth-settings-btn')?.addEventListener('click', () => {
|
||||
loadTotpSettings();
|
||||
openModal('totp-settings-modal');
|
||||
});
|
||||
|
||||
// Close settings modal
|
||||
document.getElementById('totp-modal-close')?.addEventListener('click', () => {
|
||||
closeModal('totp-settings-modal');
|
||||
});
|
||||
|
||||
// Backdrop click to close
|
||||
document.getElementById('totp-settings-modal')?.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'totp-settings-modal') {
|
||||
closeModal('totp-settings-modal');
|
||||
}
|
||||
});
|
||||
|
||||
// Update auth card on page load
|
||||
window._updateAuthCard = updateAuthCard;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/totp/config');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const active = data.config.enabled && data.config.isSetUp;
|
||||
updateAuthCard(active, data.config.sessionDuration);
|
||||
}
|
||||
} catch (e) { console.error('[AuthCard] Failed to update:', e); }
|
||||
})();
|
||||
|
||||
})();
|
||||
365
status/js/tour-manager.js
Normal file
365
status/js/tour-manager.js
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Tour Manager
|
||||
* Orchestrates the onboarding tour using Driver.js
|
||||
*/
|
||||
|
||||
(function(window) {
|
||||
'use strict';
|
||||
|
||||
class TourManager {
|
||||
constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
|
||||
this.progressTracker = progressTracker;
|
||||
this.themeAdapter = themeAdapter;
|
||||
this.dnsTemplateSelector = dnsTemplateSelector;
|
||||
this.driver = null;
|
||||
this.currentStepIndex = 0;
|
||||
this.isActive = false;
|
||||
this.resizeHandler = null;
|
||||
this.layoutChangeHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Driver.js with theme-aware configuration
|
||||
*/
|
||||
async initializeDriver() {
|
||||
// Driver.js v1.x IIFE: window.driver.js.driver is the factory function
|
||||
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
|
||||
|
||||
if (typeof driverFactory !== 'function') {
|
||||
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver);
|
||||
return false;
|
||||
}
|
||||
|
||||
const themeConfig = this.themeAdapter.getDriverTheme();
|
||||
|
||||
this.driver = driverFactory({
|
||||
showProgress: true,
|
||||
showButtons: ['next', 'previous', 'close'],
|
||||
allowClose: true,
|
||||
overlayClickNext: false,
|
||||
overlayOpacity: 0.6,
|
||||
stagePadding: 12,
|
||||
stageRadius: 12,
|
||||
allowKeyboardControl: true,
|
||||
popoverClass: 'dashcaddy-popover',
|
||||
animate: true,
|
||||
smoothScroll: true,
|
||||
onDestroyed: () => this.onTourComplete(),
|
||||
onDestroyStarted: () => {
|
||||
if (!this.progressTracker.isTourCompleted()) {
|
||||
this.onTourSkip();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply theme
|
||||
this.themeAdapter.applyTheme(this.driver);
|
||||
|
||||
// Listen for theme changes
|
||||
this.themeAdapter.onThemeChange(() => {
|
||||
this.themeAdapter.applyTheme(this.driver);
|
||||
});
|
||||
|
||||
// Set up dynamic repositioning
|
||||
this.setupDynamicRepositioning();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tour should auto-start
|
||||
*/
|
||||
shouldAutoStart() {
|
||||
return !this.progressTracker.isTourCompleted() &&
|
||||
this.progressTracker.getCurrentStep() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the onboarding tour
|
||||
*/
|
||||
async startTour() {
|
||||
if (!this.driver) {
|
||||
const initialized = await this.initializeDriver();
|
||||
if (!initialized) return;
|
||||
}
|
||||
|
||||
// Get active tooltips (filtered by conditions)
|
||||
const allTooltips = window.TooltipDefinitions.getSortedTooltips();
|
||||
|
||||
// Filter out completed tooltips
|
||||
const completedIds = this.progressTracker.getCompletedTooltips();
|
||||
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
|
||||
|
||||
if (activeTooltips.length === 0) {
|
||||
console.log('[TourManager] No tooltips to show');
|
||||
this.progressTracker.markTourCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to Driver.js steps with navigation logic
|
||||
const steps = activeTooltips.map((tooltip, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === activeTooltips.length - 1;
|
||||
|
||||
const step = {
|
||||
element: tooltip.element,
|
||||
popover: {
|
||||
title: tooltip.popover.title,
|
||||
description: tooltip.popover.description,
|
||||
side: tooltip.popover.position || 'bottom',
|
||||
align: tooltip.popover.align || 'start',
|
||||
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
|
||||
showProgress: tooltip.popover.showProgress !== false,
|
||||
onNextClick: () => {
|
||||
this.progressTracker.markTooltipCompleted(tooltip.id);
|
||||
this.progressTracker.setCurrentStep(index + 1);
|
||||
this.currentStepIndex = index + 1;
|
||||
this.driver.moveNext();
|
||||
},
|
||||
onPrevClick: () => {
|
||||
this.progressTracker.setCurrentStep(Math.max(0, index - 1));
|
||||
this.currentStepIndex = Math.max(0, index - 1);
|
||||
this.driver.movePrevious();
|
||||
},
|
||||
onCloseClick: () => {
|
||||
this.skipTour();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add custom handlers for DNS tooltip
|
||||
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
|
||||
step.popover.onSetupNowClick = () => {
|
||||
console.log('[TourManager] Opening DNS template selector');
|
||||
this.dnsTemplateSelector.showTemplateSelector();
|
||||
// Mark tooltip as completed and move to next
|
||||
this.progressTracker.markTooltipCompleted(tooltip.id);
|
||||
this.progressTracker.setCurrentStep(index + 1);
|
||||
this.currentStepIndex = index + 1;
|
||||
this.driver.moveNext();
|
||||
};
|
||||
|
||||
step.popover.onLaterClick = () => {
|
||||
console.log('[TourManager] DNS setup deferred');
|
||||
this.progressTracker.markDnsSetupDeferred();
|
||||
// Mark tooltip as completed and move to next
|
||||
this.progressTracker.markTooltipCompleted(tooltip.id);
|
||||
this.progressTracker.setCurrentStep(index + 1);
|
||||
this.currentStepIndex = index + 1;
|
||||
this.driver.moveNext();
|
||||
};
|
||||
}
|
||||
|
||||
return step;
|
||||
});
|
||||
|
||||
this.isActive = true;
|
||||
this.driver.setSteps(steps);
|
||||
this.driver.drive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume tour from last step
|
||||
*/
|
||||
async resumeTour() {
|
||||
const currentStep = this.progressTracker.getCurrentStep();
|
||||
if (currentStep > 0) {
|
||||
await this.startTour();
|
||||
// Driver.js will start from beginning, we'd need to skip to current step
|
||||
// This is a simplified implementation
|
||||
} else {
|
||||
await this.startTour();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the entire tour
|
||||
*/
|
||||
skipTour() {
|
||||
if (this.driver) {
|
||||
this.driver.destroy();
|
||||
}
|
||||
this.cleanupDynamicRepositioning();
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart tour from beginning
|
||||
*/
|
||||
async restartTour() {
|
||||
this.progressTracker.resetProgress();
|
||||
await this.startTour();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a specific tooltip by ID
|
||||
*/
|
||||
async showTooltip(tooltipId) {
|
||||
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
|
||||
if (!tooltip) {
|
||||
console.error(`[TourManager] Tooltip not found: ${tooltipId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.driver) {
|
||||
await this.initializeDriver();
|
||||
}
|
||||
|
||||
const step = {
|
||||
element: tooltip.element,
|
||||
popover: {
|
||||
title: tooltip.popover.title,
|
||||
description: tooltip.popover.description,
|
||||
side: tooltip.popover.position || 'bottom',
|
||||
align: tooltip.popover.align || 'start'
|
||||
}
|
||||
};
|
||||
|
||||
this.driver.highlight(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show "What's New" tour - only tooltips marked as new features
|
||||
*/
|
||||
async showWhatsNew() {
|
||||
if (!this.driver) {
|
||||
const initialized = await this.initializeDriver();
|
||||
if (!initialized) return;
|
||||
}
|
||||
|
||||
// Get only new feature tooltips
|
||||
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
|
||||
|
||||
if (newFeatureTooltips.length === 0) {
|
||||
console.log('[TourManager] No new features to show');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
|
||||
|
||||
// Convert to Driver.js steps
|
||||
const steps = newFeatureTooltips.map((tooltip, index) => {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === newFeatureTooltips.length - 1;
|
||||
|
||||
return {
|
||||
element: tooltip.element,
|
||||
popover: {
|
||||
title: `✨ NEW: ${tooltip.popover.title}`,
|
||||
description: tooltip.popover.description,
|
||||
side: tooltip.popover.position || 'bottom',
|
||||
align: tooltip.popover.align || 'start',
|
||||
showButtons: this._getButtonsForStep(tooltip, isFirst, isLast),
|
||||
showProgress: true,
|
||||
onNextClick: () => {
|
||||
this.driver.moveNext();
|
||||
},
|
||||
onPrevClick: () => {
|
||||
this.driver.movePrevious();
|
||||
},
|
||||
onCloseClick: () => {
|
||||
this.skipTour();
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
this.isActive = true;
|
||||
this.driver.setSteps(steps);
|
||||
this.driver.drive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up dynamic repositioning for window resize and layout changes
|
||||
*/
|
||||
setupDynamicRepositioning() {
|
||||
// Window resize handler with debouncing
|
||||
let resizeTimeout;
|
||||
this.resizeHandler = () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
if (this.isActive && this.driver) {
|
||||
console.log('[TourManager] Window resized, repositioning tooltip');
|
||||
this.driver.refresh();
|
||||
}
|
||||
}, 150); // Debounce for 150ms
|
||||
};
|
||||
|
||||
// Layout change handler (for theme changes, DOM mutations)
|
||||
this.layoutChangeHandler = () => {
|
||||
if (this.isActive && this.driver) {
|
||||
console.log('[TourManager] Layout changed, repositioning tooltip');
|
||||
// Small delay to allow layout to settle
|
||||
setTimeout(() => {
|
||||
if (this.driver) {
|
||||
this.driver.refresh();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
|
||||
// Listen for theme changes (already handled by ThemeAdapter, but also trigger reposition)
|
||||
this.themeAdapter.onThemeChange(this.layoutChangeHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up dynamic repositioning listeners
|
||||
*/
|
||||
cleanupDynamicRepositioning() {
|
||||
if (this.resizeHandler) {
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buttons to show for a specific step
|
||||
* @private
|
||||
*/
|
||||
_getButtonsForStep(tooltip, isFirst, isLast) {
|
||||
// Check if tooltip has custom buttons defined
|
||||
if (tooltip.popover.showButtons) {
|
||||
return tooltip.popover.showButtons;
|
||||
}
|
||||
|
||||
// Default button configuration
|
||||
const buttons = [];
|
||||
|
||||
if (!isFirst) {
|
||||
buttons.push('previous');
|
||||
}
|
||||
|
||||
if (!isLast) {
|
||||
buttons.push('next');
|
||||
} else {
|
||||
buttons.push('close');
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tour completion
|
||||
*/
|
||||
onTourComplete() {
|
||||
this.progressTracker.markTourCompleted();
|
||||
this.isActive = false;
|
||||
console.log('[TourManager] Tour completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tour skip
|
||||
*/
|
||||
onTourSkip() {
|
||||
// Save current progress but don't mark as completed
|
||||
console.log('[TourManager] Tour skipped');
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.TourManager = TourManager;
|
||||
console.log('[TourManager] Module loaded');
|
||||
|
||||
})(window);
|
||||
274
status/js/update-management.js
Normal file
274
status/js/update-management.js
Normal file
@@ -0,0 +1,274 @@
|
||||
// ========== UPDATE MANAGEMENT ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('updates-modal', `<div id="updates-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>⬆️ Update Management</h3>
|
||||
<p class="modal-subtitle">
|
||||
Check for container image updates, apply them, and manage rollbacks.
|
||||
</p>
|
||||
|
||||
<div class="panel-tabs">
|
||||
<button class="panel-tab active" data-panel="updates-available">Available</button>
|
||||
<button class="panel-tab" data-panel="updates-history">History</button>
|
||||
<button class="panel-tab" data-panel="updates-auto">Auto-Update</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Available Updates -->
|
||||
<div id="updates-available" class="panel-section active">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<button id="updates-check-btn" class="btn-accent-solid">🔍 Check for Updates</button>
|
||||
</div>
|
||||
<div id="updates-available-container" style="max-height: 450px; overflow-y: auto;">
|
||||
<div class="panel-empty"><span class="empty-icon">📦</span> Click "Check for Updates" to scan containers.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: History -->
|
||||
<div id="updates-history" class="panel-section">
|
||||
<div id="updates-history-container" class="scroll-container">
|
||||
<div class="panel-empty"><span class="brand-spinner"></span> Loading update history...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Auto-Update -->
|
||||
<div id="updates-auto" class="panel-section">
|
||||
<div id="updates-auto-container" class="scroll-container">
|
||||
<div class="panel-empty"><span class="empty-icon">🤖</span> Loading auto-update configuration...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-bottom-bar">
|
||||
<span id="updates-last-check" class="text-auto-right"></span>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="updates-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('updates-modal');
|
||||
const openBtn = document.getElementById('updates-btn');
|
||||
const cancelBtn = document.getElementById('updates-cancel');
|
||||
const checkBtn = document.getElementById('updates-check-btn');
|
||||
const availableContainer = document.getElementById('updates-available-container');
|
||||
const historyContainer = document.getElementById('updates-history-container');
|
||||
const autoContainer = document.getElementById('updates-auto-container');
|
||||
const lastCheckSpan = document.getElementById('updates-last-check');
|
||||
|
||||
async function loadAvailable() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/updates/available');
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error);
|
||||
|
||||
const updates = data.updates || [];
|
||||
if (updates.length === 0) {
|
||||
availableContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">✅</span>All containers are up to date.</div>';
|
||||
lastCheckSpan.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
||||
for (const u of updates) {
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${u.containerName}</td>`;
|
||||
html += `<td style="padding: 8px; color: var(--muted);">${u.imageName}</td>`;
|
||||
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${u.currentDigest}</code></td>`;
|
||||
html += `<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${u.latestDigest}</code></td>`;
|
||||
html += `<td style="padding: 8px; text-align: right;">`;
|
||||
html += `<button class="update-now-btn" data-id="${u.containerId}" data-name="${u.containerName}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`;
|
||||
html += `<button class="rollback-btn" data-id="${u.containerId}" data-name="${u.containerName}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`;
|
||||
html += '</td></tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
availableContainer.innerHTML = html;
|
||||
lastCheckSpan.textContent = updates.length + ' update(s) available';
|
||||
|
||||
// Wire update buttons
|
||||
availableContainer.querySelectorAll('.update-now-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm(`Update "${name}" to the latest version? The container will restart.`)) return;
|
||||
btn.textContent = 'Updating...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await secureFetch(`/api/v1/updates/update/${encodeURIComponent(id)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoRollback: true })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
btn.textContent = 'Done!';
|
||||
btn.style.background = 'var(--ok-fg)';
|
||||
setTimeout(() => loadAvailable(), 2000);
|
||||
} else {
|
||||
throw new Error(d.error || 'Update failed');
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Failed';
|
||||
btn.style.color = 'var(--bad-fg)';
|
||||
showNotification('Update error: ' + e.message, 'error');
|
||||
setTimeout(() => { btn.textContent = 'Update'; btn.disabled = false; btn.style.color = ''; btn.style.background = ''; }, 3000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wire rollback buttons
|
||||
availableContainer.querySelectorAll('.rollback-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm(`Rollback "${name}" to its previous version?`)) return;
|
||||
btn.textContent = 'Rolling back...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(id)}`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
btn.textContent = 'Rolled back!';
|
||||
setTimeout(() => loadAvailable(), 2000);
|
||||
} else {
|
||||
throw new Error(d.error || 'Rollback failed');
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = 'Failed';
|
||||
showNotification('Rollback error: ' + e.message, 'error');
|
||||
setTimeout(() => { btn.textContent = 'Rollback'; btn.disabled = false; }, 3000);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
availableContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
checkBtn.textContent = '🔍 Checking...';
|
||||
checkBtn.disabled = true;
|
||||
try {
|
||||
const res = await secureFetch('/api/v1/updates/check', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error);
|
||||
checkBtn.textContent = '✅ Done!';
|
||||
await loadAvailable();
|
||||
} catch (e) {
|
||||
checkBtn.textContent = '❌ Failed';
|
||||
showNotification('Check error: ' + e.message, 'error');
|
||||
}
|
||||
setTimeout(() => { checkBtn.textContent = '🔍 Check for Updates'; checkBtn.disabled = false; }, 3000);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
historyContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||
const res = await fetch('/api/v1/updates/history?limit=50');
|
||||
const data = await res.json();
|
||||
const history = data.success && data.history ? data.history : [];
|
||||
if (history.length === 0) {
|
||||
historyContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">📋</span>No update history yet.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">Status</th></tr>';
|
||||
for (const h of history) {
|
||||
const ok = h.status === 'success';
|
||||
const dur = h.duration ? (h.duration < 1000 ? h.duration + 'ms' : Math.round(h.duration / 1000) + 's') : '-';
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);">`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${timeAgo(h.timestamp)}</td>`;
|
||||
html += `<td style="padding: 6px; font-weight: 500;">${h.containerName}</td>`;
|
||||
html += `<td style="padding: 6px; color: var(--muted);">${h.imageName}</td>`;
|
||||
html += `<td style="padding: 6px;">${dur}</td>`;
|
||||
html += `<td style="padding: 6px;"><span style="color: ${ok ? 'var(--ok-fg)' : 'var(--bad-fg)'};">${ok ? '✓ success' : '✗ failed'}</span></td>`;
|
||||
html += '</tr>';
|
||||
if (!ok && h.error) {
|
||||
html += `<tr><td colspan="5" style="padding: 4px 6px 8px; font-size: 0.78rem; color: var(--bad-fg);">${h.error}</td></tr>`;
|
||||
}
|
||||
}
|
||||
html += '</table>';
|
||||
historyContainer.innerHTML = html;
|
||||
} catch (e) {
|
||||
historyContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAutoConfig() {
|
||||
try {
|
||||
autoContainer.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';
|
||||
// Get running containers to show auto-update toggles
|
||||
const res = await fetch('/api/v1/stats/containers');
|
||||
const data = await res.json();
|
||||
const containers = data.success && data.stats ? data.stats : [];
|
||||
if (containers.length === 0) {
|
||||
autoContainer.innerHTML = '<div class="panel-empty"><span class="empty-icon">🤖</span>No running containers found.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';
|
||||
html += '<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Schedule</th><th style="padding: 8px; text-align: left;">Auto-Rollback</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';
|
||||
for (const c of containers) {
|
||||
const name = c.name || c.Names?.[0]?.replace(/^\//, '') || c.Id?.substring(0, 12);
|
||||
const cid = c.containerId || c.Id;
|
||||
html += `<tr style="border-bottom: 1px solid var(--border);" data-container-id="${cid}">`;
|
||||
html += `<td style="padding: 8px; font-weight: 500;">${name}</td>`;
|
||||
html += `<td style="padding: 8px;">
|
||||
<select class="auto-schedule" data-id="${cid}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value="">Disabled</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select></td>`;
|
||||
html += `<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${cid}" checked /></td>`;
|
||||
html += `<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${cid}" data-name="${name}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
autoContainer.innerHTML = html;
|
||||
|
||||
// Wire save buttons
|
||||
autoContainer.querySelectorAll('.save-auto-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
const row = btn.closest('tr');
|
||||
const schedule = row.querySelector('.auto-schedule').value;
|
||||
const rollback = row.querySelector('.auto-rollback').checked;
|
||||
btn.textContent = 'Saving...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(id)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !!schedule, schedule: schedule || 'weekly', autoRollback: rollback })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
btn.textContent = '✓ Saved';
|
||||
} else {
|
||||
throw new Error(d.error);
|
||||
}
|
||||
} catch (e) {
|
||||
btn.textContent = '✗ Error';
|
||||
showNotification('Save error: ' + e.message, 'error');
|
||||
}
|
||||
setTimeout(() => { btn.textContent = 'Save'; btn.disabled = false; }, 2000);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
autoContainer.innerHTML = `<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
checkBtn?.addEventListener('click', checkForUpdates);
|
||||
openBtn?.addEventListener('click', () => {
|
||||
modal?.classList.add('show');
|
||||
loadAvailable();
|
||||
});
|
||||
wireModal(modal, cancelBtn);
|
||||
|
||||
// Lazy-load tabs
|
||||
document.querySelector('[data-panel="updates-history"]')?.addEventListener('click', loadHistory);
|
||||
document.querySelector('[data-panel="updates-auto"]')?.addEventListener('click', loadAutoConfig);
|
||||
})();
|
||||
229
status/js/weather.js
Normal file
229
status/js/weather.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// ========== WEATHER WIDGET ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('weather-modal', `<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
<label for="weather-location-input">Location:</label>
|
||||
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
||||
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
||||
<div style="margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Units</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="imperial"><span class="weather-unit-card">°F / mph</span></label>
|
||||
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">°C / km/h</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);
|
||||
|
||||
/* Weather Widget — powered by Open-Meteo (free, no API key) */
|
||||
const LOCATION_KEY = 'weather-location';
|
||||
const LEGACY_KEY = 'weather-zip';
|
||||
const GEO_CACHE_KEY = 'weather-geo';
|
||||
const UNIT_KEY = 'weather-unit';
|
||||
|
||||
// Migrate from old ZIP-only key
|
||||
if (!safeGet(LOCATION_KEY) && safeGet(LEGACY_KEY)) {
|
||||
safeSet(LOCATION_KEY, safeGet(LEGACY_KEY));
|
||||
}
|
||||
|
||||
function getUnit() {
|
||||
return safeGet(UNIT_KEY) || 'imperial';
|
||||
}
|
||||
|
||||
function getWeatherElements() {
|
||||
return {
|
||||
icon: document.querySelector('.weather-icon'),
|
||||
temp: document.querySelector('.weather-temp'),
|
||||
condition: document.querySelector('.weather-condition'),
|
||||
location: document.querySelector('.weather-location'),
|
||||
wind: document.querySelector('.weather-wind')
|
||||
};
|
||||
}
|
||||
|
||||
// WMO Weather Code mapping
|
||||
const wmoDescriptions = {
|
||||
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
|
||||
45: 'Fog', 48: 'Rime fog',
|
||||
51: 'Light drizzle', 53: 'Drizzle', 55: 'Dense drizzle',
|
||||
56: 'Freezing drizzle', 57: 'Dense freezing drizzle',
|
||||
61: 'Light rain', 63: 'Moderate rain', 65: 'Heavy rain',
|
||||
66: 'Light freezing rain', 67: 'Heavy freezing rain',
|
||||
71: 'Light snow', 73: 'Moderate snow', 75: 'Heavy snow', 77: 'Snow grains',
|
||||
80: 'Light showers', 81: 'Moderate showers', 82: 'Violent showers',
|
||||
85: 'Light snow showers', 86: 'Heavy snow showers',
|
||||
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm'
|
||||
};
|
||||
|
||||
const wmoIcons = {
|
||||
0: '☀️', 1: '🌤️', 2: '⛅', 3: '☁️',
|
||||
45: '🌫️', 48: '🌫️',
|
||||
51: '🌦️', 53: '🌦️', 55: '🌦️',
|
||||
56: '🌨️', 57: '🌨️',
|
||||
61: '🌦️', 63: '🌧️', 65: '🌧️',
|
||||
66: '🌨️', 67: '🌨️',
|
||||
71: '🌨️', 73: '❄️', 75: '❄️', 77: '❄️',
|
||||
80: '🌦️', 81: '🌧️', 82: '🌧️',
|
||||
85: '🌨️', 86: '❄️',
|
||||
95: '⛈️', 96: '⛈️', 99: '⛈️'
|
||||
};
|
||||
|
||||
// Wind direction from degrees
|
||||
const windDirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
|
||||
function degToDir(deg) {
|
||||
return windDirs[Math.round(deg / 22.5) % 16];
|
||||
}
|
||||
|
||||
// Geocode location via Open-Meteo, with localStorage cache
|
||||
async function geocodeLocation(query) {
|
||||
const cached = safeGet(GEO_CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const geo = JSON.parse(cached);
|
||||
if (geo.query === query) return geo;
|
||||
} catch {}
|
||||
}
|
||||
const resp = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=1&language=en&format=json`);
|
||||
if (!resp.ok) throw new Error('Geocoding failed');
|
||||
const data = await resp.json();
|
||||
if (!data.results || !data.results.length) throw new Error('Location not found');
|
||||
const r = data.results[0];
|
||||
const geo = {
|
||||
query,
|
||||
lat: r.latitude,
|
||||
lon: r.longitude,
|
||||
city: r.name,
|
||||
state: r.admin1 || '',
|
||||
country: r.country || '',
|
||||
countryCode: r.country_code || ''
|
||||
};
|
||||
safeSet(GEO_CACHE_KEY, JSON.stringify(geo));
|
||||
return geo;
|
||||
}
|
||||
|
||||
function formatLocation(geo) {
|
||||
// For US locations show "City, State", for others show "City, Country"
|
||||
if (geo.countryCode === 'US' && geo.state) {
|
||||
return `${geo.city}, ${geo.state}`;
|
||||
}
|
||||
if (geo.country) {
|
||||
return `${geo.city}, ${geo.country}`;
|
||||
}
|
||||
return geo.city;
|
||||
}
|
||||
|
||||
async function fetchWeather(location) {
|
||||
try {
|
||||
const geo = await geocodeLocation(location);
|
||||
const unit = getUnit();
|
||||
const tempUnit = unit === 'metric' ? 'celsius' : 'fahrenheit';
|
||||
const windUnit = unit === 'metric' ? 'kmh' : 'mph';
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${geo.lat}&longitude=${geo.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${tempUnit}&wind_speed_unit=${windUnit}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('Weather fetch failed');
|
||||
const data = await resp.json();
|
||||
const c = data.current;
|
||||
const code = c.weather_code;
|
||||
return {
|
||||
temp: Math.round(c.temperature_2m),
|
||||
condition: wmoDescriptions[code] || 'Unknown',
|
||||
icon: wmoIcons[code] || '🌤️',
|
||||
locationStr: formatLocation(geo),
|
||||
windSpeed: Math.round(c.wind_speed_10m),
|
||||
windDir: degToDir(c.wind_direction_10m),
|
||||
unit
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Weather fetch failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateWeather() {
|
||||
const weatherWidget = getWeatherElements();
|
||||
if (!weatherWidget.icon || !weatherWidget.temp || !weatherWidget.condition || !weatherWidget.location || !weatherWidget.wind) {
|
||||
console.warn('Weather widget elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const location = safeGet(LOCATION_KEY);
|
||||
if (!location) {
|
||||
weatherWidget.location.textContent = 'Set Location';
|
||||
weatherWidget.temp.textContent = '--\u00B0';
|
||||
weatherWidget.condition.textContent = 'Click \u2699\uFE0F to configure';
|
||||
weatherWidget.wind.textContent = '--';
|
||||
weatherWidget.icon.innerHTML = '<span class="weather-emoji">\uD83C\uDF24\uFE0F</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const weather = await fetchWeather(location);
|
||||
if (weather) {
|
||||
const tempSuffix = weather.unit === 'metric' ? '\u00B0C' : '\u00B0F';
|
||||
const windLabel = weather.unit === 'metric' ? 'km/h' : 'mph';
|
||||
weatherWidget.location.textContent = weather.locationStr;
|
||||
weatherWidget.temp.textContent = `${weather.temp}${tempSuffix}`;
|
||||
weatherWidget.condition.textContent = weather.condition;
|
||||
weatherWidget.wind.textContent = `Wind: ${weather.windSpeed} ${windLabel} ${weather.windDir}`;
|
||||
weatherWidget.icon.innerHTML = `<span class="weather-emoji">${weather.icon}</span>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Weather update error:', error);
|
||||
weatherWidget.location.textContent = 'Weather Error';
|
||||
weatherWidget.temp.textContent = 'Error';
|
||||
weatherWidget.condition.textContent = 'Failed to load';
|
||||
weatherWidget.wind.textContent = '--';
|
||||
}
|
||||
}
|
||||
|
||||
// Weather settings modal
|
||||
const modal = document.getElementById('weather-modal');
|
||||
const locationInput = document.getElementById('weather-location-input');
|
||||
|
||||
document.getElementById('weather-settings')?.addEventListener('click', () => {
|
||||
locationInput.value = safeGet(LOCATION_KEY) || '';
|
||||
// Set unit radio
|
||||
const unit = getUnit();
|
||||
const radio = modal.querySelector(`input[name="weather-unit-radio"][value="${unit}"]`);
|
||||
if (radio) radio.checked = true;
|
||||
modal.classList.add('show');
|
||||
locationInput.focus();
|
||||
});
|
||||
|
||||
document.getElementById('weather-cancel')?.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
});
|
||||
|
||||
document.getElementById('weather-save')?.addEventListener('click', () => {
|
||||
const loc = locationInput.value.trim();
|
||||
if (loc) {
|
||||
// Clear geo cache when location changes so it re-geocodes
|
||||
const oldLoc = safeGet(LOCATION_KEY);
|
||||
if (oldLoc !== loc) safeSet(GEO_CACHE_KEY, '');
|
||||
safeSet(LOCATION_KEY, loc);
|
||||
// Save unit preference
|
||||
const unitRadio = modal.querySelector('input[name="weather-unit-radio"]:checked');
|
||||
const newUnit = unitRadio ? unitRadio.value : 'imperial';
|
||||
const oldUnit = getUnit();
|
||||
safeSet(UNIT_KEY, newUnit);
|
||||
// Clear geo cache if unit changed (need fresh weather data)
|
||||
if (oldUnit !== newUnit) safeSet(GEO_CACHE_KEY, '');
|
||||
modal.classList.remove('show');
|
||||
updateWeather();
|
||||
} else {
|
||||
showNotification('Please enter a location (e.g., Hamburg, London, 90210)', 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
wireModal(modal);
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize weather
|
||||
updateWeather();
|
||||
// Update weather every 10 minutes
|
||||
setInterval(updateWeather, DC.POLL.WEATHER);
|
||||
})();
|
||||
Reference in New Issue
Block a user