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:
2026-03-05 02:26:12 -08:00
commit f61e85d9a7
337 changed files with 75282 additions and 0 deletions

40
status/js/README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

140
status/js/audit-log.js Normal file
View 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
View 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
View 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
View 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();
});
});
})();

View 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
View 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
View 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
View 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
View 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}&timestamps=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;
})();

View 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;
})();

View 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;
})();

View 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;
})();

View 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>`);
})();

View 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">&times;</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

File diff suppressed because one or more lines are too long

259
status/js/error-handler.js Normal file
View 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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** 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
View 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
View 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);
}
});
})();

View 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
View 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);
});
})();

View 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);
}
});
}
})();

View 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
View 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
View 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');
});
})();

View 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
View 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)} &bull; IP: ${escapeHtml(ip)} ${tailscale ? '&bull; 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();
})();

View 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);
})();

View 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
View 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();
}
};
})();

View 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 };
})();

View 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: '&#10003;', text: 'Connected' },
needs_key: { bg: '#f39c12', icon: '&#128273;', text: 'Needs API Key' },
not_found: { bg: 'var(--muted)', icon: '&mdash;', text: 'Not Found' },
error: { bg: 'var(--bad-fg)', icon: '&#10007;', 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 &middot;
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey > 0 ? ` &middot; <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);">&#10003; 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 ? '&#10003; Radarr' : '&#10007; Radarr'} &middot;
${cs.sonarr ? '&#10003; Sonarr' : '&#10007; Sonarr'} &middot;
${cs.plex ? '&#10003; Plex' : '&#10007; 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);">&#10003; ${escapeHtml(data.appName || 'Connected')} v${escapeHtml(data.version || '')}</span>`;
} else {
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(data.error)}</span>`;
}
} catch (e) {
statusSpan.innerHTML = `<span style="color: var(--bad-fg);">&#10007; ${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);">&#10003;</span>'
: '<span style="color: var(--bad-fg);">&#10007;</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 ? '&#10003;' : '&#9888;';
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);">&#10003;</span>'
: '<span style="color: var(--bad-fg);">&#10007;</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
View 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
View 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 &#9660;</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 &#9650;'
: 'Show advanced colors &#9660;';
});
// ─── 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 &#9660;';
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
View 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;
})();

View 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> &mdash; 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> &mdash; your Technitium DNS servers</li>
<li><strong>Internet</strong> &mdash; live connectivity check with packet indicators</li>
<li><strong>Auth</strong> &mdash; TOTP authentication status</li>
<li><strong>DashCA</strong> &mdash; 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> &mdash; green = online, red = offline (pulses when down)</li>
<li><strong>ON/OFF badge</strong> &mdash; current state at a glance</li>
<li><strong>Response time</strong> &mdash; how fast the service responds (color-coded)</li>
<li><strong>Uptime bar</strong> &mdash; 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 &mdash; 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 &mdash; 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;">&#9733; 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 &mdash; 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> &mdash; auto-detects running containers</li>
<li><strong>External services</strong> &mdash; any URL you want to monitor</li>
<li><strong>Test services</strong> &mdash; 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 &rarr; 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> &mdash; live CPU, memory, and disk usage for every container</li>
<li><strong>Health</strong> &mdash; service health dashboard with uptime history and alerts</li>
<li><strong>Updates</strong> &mdash; 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> &mdash; view error logs from your API server and services</li>
<li><strong>Alerts</strong> &mdash; configure notification rules (email, webhook, etc.)</li>
<li><strong>Audit</strong> &mdash; 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 &amp; Configuration',
description: `
<p>Click <strong>Admin</strong> for setup and maintenance:</p>
<ul>
<li><strong>Tokens</strong> &mdash; manage API keys for DNS and other integrations</li>
<li><strong>Export / Import</strong> &mdash; backup and restore your dashboard configuration</li>
<li><strong>Backup</strong> &mdash; full system backup and restore</li>
<li><strong>License</strong> &mdash; activate your license code to unlock premium features</li>
<li><strong>API</strong> &mdash; 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 &mdash; 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 &mdash; 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> &mdash; press <code>?</code> anytime to see them all</li>
<li><strong>DashCA</strong> &mdash; 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;">&#9733; 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> &mdash; sign into every app automatically, no more password juggling</li>
<li><strong>Recipes</strong> &mdash; deploy full stacks (media server, dev environment) in one click</li>
<li><strong>Docker Swarm</strong> &mdash; 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 &rarr; 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 &rarr; 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
View 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
View 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
View 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);

View 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
View 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 &ldquo;City, Country&rdquo;</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">&deg;F / mph</span></label>
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;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}&current=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);
})();