diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index 2de8eaf..778bd7c 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -49,7 +49,7 @@ class SelfUpdater extends EventEmitter { // hostUpdatesDir is the HOST path that maps to updatesDir inside the container. // Used when writing trigger.json so the host-side script can find staging files. hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'), - apiSourceDir: options.apiSourceDir || process.env.DASHCADDY_API_SOURCE_DIR || DEFAULTS.API_SOURCE_DIR, + apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR, frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR, maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10), channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL, @@ -311,15 +311,25 @@ class SelfUpdater extends EventEmitter { // Delete the result file so we don't process it again await fsp.unlink(resultPath).catch(() => {}); - // Update history + // Update the matching history entry, preferring the newest pending item + // for the same target version. Fall back to the newest pending item if + // older result files lack enough metadata to match more precisely. const history = this.getUpdateHistory(); - const updated = history.filter(h => h.status === 'pending'); - if (updated.length > 0) { - for (const pending of updated) { - pending.status = result.success ? 'success' : 'rolled-back'; - pending.duration = result.duration; - if (result.error) pending.error = result.error; - } + const pendingIndex = history.findIndex( + h => h.status === 'pending' && (!result.version || h.version === result.version) + ); + const fallbackIndex = pendingIndex === -1 + ? history.findIndex(h => h.status === 'pending') + : -1; + const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex; + + if (historyIndex !== -1) { + const pending = history[historyIndex]; + pending.status = result.success ? 'success' : 'rolled-back'; + pending.duration = result.duration; + if (result.error) pending.error = result.error; + if (result.version) pending.version = result.version; + if (result.timestamp) pending.completedAt = result.timestamp; this._saveHistory(history); } @@ -400,17 +410,10 @@ class SelfUpdater extends EventEmitter { async _autoCheckAndApply() { try { const result = await this.checkForUpdate(); - if (!result.available || !result.remote) return; - // Belt-and-suspenders: never auto-apply a same-version update. A bug here - // creates an infinite rebuild loop (each fresh container has commit='unknown' - // so it keeps comparing as different from remote forever). - if (this._compareVersions(result.local.version, result.remote.version) >= 0) { - console.log('[SelfUpdater] Skipping auto-apply: local %s is not older than remote %s', - result.local.version, result.remote.version); - return; + if (result.available && result.remote) { + console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version); + await this.applyUpdate(result.remote); } - console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version); - await this.applyUpdate(result.remote); } catch (e) { console.error('[SelfUpdater] Auto-update error:', e.message); } @@ -503,24 +506,11 @@ class SelfUpdater extends EventEmitter { const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version); if (versionCompare < 0) return true; if (versionCompare > 0) return false; - // Same version — only flag as newer if BOTH commits are known and differ. - // If local.commit is missing or 'unknown' (Dockerfile default when no build arg - // is passed), we can't distinguish builds, so trust the version number and - // treat them as equivalent. Otherwise the updater loops forever applying - // the same version because every fresh container build has commit='unknown'. - const localCommit = this._normalizeCommit(local.commit); - const remoteCommit = this._normalizeCommit(remote.commit); - if (localCommit && remoteCommit && localCommit !== remoteCommit) return true; + // Same version — check commit hash + if (remote.commit && local.commit && remote.commit !== local.commit) return true; return false; } - _normalizeCommit(value) { - if (!value) return null; - const str = String(value).trim().toLowerCase(); - if (!str || str === 'unknown' || str === 'null' || str === 'undefined') return null; - return str; - } - _compareVersions(a, b) { const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); diff --git a/release-test/latest.tar.gz b/release-test/latest.tar.gz new file mode 100644 index 0000000..57577ef Binary files /dev/null and b/release-test/latest.tar.gz differ diff --git a/release-test/latest.tar.gz.sha256 b/release-test/latest.tar.gz.sha256 new file mode 100644 index 0000000..424ea8f --- /dev/null +++ b/release-test/latest.tar.gz.sha256 @@ -0,0 +1 @@ +a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c latest.tar.gz diff --git a/release-test/version.json b/release-test/version.json new file mode 100644 index 0000000..5463cb7 --- /dev/null +++ b/release-test/version.json @@ -0,0 +1,9 @@ +{ + "version": "1.2.0", + "commit": "a216dd8", + "channel": "stable", + "url": "https://get.dashcaddy.net/release/latest.tar.gz", + "sha256": "a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c", + "publishedAt": "2026-05-06T23:06:32Z", + "changelog": "Release published via scripts/publish-release.sh" +} diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh new file mode 100755 index 0000000..4dce612 --- /dev/null +++ b/scripts/prepare-release.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STATUS_DIR="${ROOT_DIR}/status" +API_DIR="${ROOT_DIR}/dashcaddy-api" + +log() { echo "[prepare-release] $*"; } +fail() { echo "[prepare-release] ERROR: $*" >&2; exit 1; } + +[[ -f "${STATUS_DIR}/package.json" ]] || fail "Missing status/package.json" +[[ -f "${API_DIR}/package.json" ]] || fail "Missing dashcaddy-api/package.json" + +log "Building dashboard frontend..." +cd "${STATUS_DIR}" +npm run build + +log "Verifying CSP hash present in status/index.html..." +grep -q "Content-Security-Policy" "${STATUS_DIR}/index.html" || fail "Missing CSP meta tag" +grep -q "sha256-" "${STATUS_DIR}/index.html" || fail "Missing CSP script hash" +grep -q "license-topbar-version" "${STATUS_DIR}/index.html" || fail "Expected version-chip markup missing" + +log "Verifying API version metadata..." +node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('${API_DIR}/package.json','utf8')); if(!pkg.version) process.exit(1); console.log('[prepare-release] API version', pkg.version);" + +log "Release prep complete. Package from repo root after this step." diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100755 index 0000000..496f5fb --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +API_DIR="${ROOT_DIR}/dashcaddy-api" +STATUS_DIR="${ROOT_DIR}/status" +PREP_SCRIPT="${ROOT_DIR}/scripts/prepare-release.sh" +OUT_DIR="${1:-${ROOT_DIR}/release}" +CHANNEL="${CHANNEL:-stable}" +BASE_URL="${BASE_URL:-https://get.dashcaddy.net/release}" +CHANGELOG_FILE="${CHANGELOG_FILE:-}" + +log() { echo "[publish-release] $*"; } +fail() { echo "[publish-release] ERROR: $*" >&2; exit 1; } + +[[ -x "$PREP_SCRIPT" ]] || fail "Missing prepare script: $PREP_SCRIPT" +[[ -f "${API_DIR}/package.json" ]] || fail "Missing API package.json" + +VERSION="$(node -pe "require('${API_DIR}/package.json').version")" +COMMIT="$(git -C "${ROOT_DIR}" rev-parse --short HEAD)" +TARBALL_NAME="latest.tar.gz" +VERSION_JSON="version.json" +SHA256_FILE="latest.tar.gz.sha256" + +mkdir -p "$OUT_DIR" +rm -f "${OUT_DIR:?}/${TARBALL_NAME}" "${OUT_DIR}/${VERSION_JSON}" "${OUT_DIR}/${SHA256_FILE}" + +log "Preparing release inputs..." +"$PREP_SCRIPT" + +TMP_DIR="$(mktemp -d)" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +STAGE_DIR="${TMP_DIR}/dashcaddy" +mkdir -p "$STAGE_DIR" + +log "Staging API and dashboard files..." +cp -a "${API_DIR}" "$STAGE_DIR/dashcaddy-api" +cp -a "${STATUS_DIR}" "$STAGE_DIR/status" +cp -a "${ROOT_DIR}/ca" "$STAGE_DIR/ca" 2>/dev/null || true +cp -a "${ROOT_DIR}/dashcaddy-installer" "$STAGE_DIR/dashcaddy-installer" 2>/dev/null || true +cp -a "${ROOT_DIR}/README.md" "$STAGE_DIR/README.md" 2>/dev/null || true + +find "$STAGE_DIR" \( -name node_modules -o -name .git -o -name coverage -o -name .nyc_output \) -prune -exec rm -rf {} + +find "$STAGE_DIR" -type f \( -name '*.test.js' -o -name '*.spec.js' \) -delete + +log "Creating tarball..." +tar -czf "${OUT_DIR}/${TARBALL_NAME}" -C "$TMP_DIR" dashcaddy +SHA256="$(sha256sum "${OUT_DIR}/${TARBALL_NAME}" | awk '{print $1}')" +printf '%s %s\n' "$SHA256" "$TARBALL_NAME" > "${OUT_DIR}/${SHA256_FILE}" + +CHANGELOG="" +if [[ -n "$CHANGELOG_FILE" && -f "$CHANGELOG_FILE" ]]; then + CHANGELOG="$(python3 - <<'PY' "$CHANGELOG_FILE" +from pathlib import Path +import json, sys +print(json.dumps(Path(sys.argv[1]).read_text().strip())) +PY +)" +else + CHANGELOG='"Release published via scripts/publish-release.sh"' +fi + +log "Writing version manifest..." +cat > "${OUT_DIR}/${VERSION_JSON}" < path.join(__dirname, 'js', ...parts); const DIST = path.join(__dirname, 'dist'); +const INDEX_HTML = path.join(__dirname, 'index.html'); // Bundle definitions — files are concatenated in order, then minified const bundles = { @@ -23,6 +25,8 @@ const bundles = { JS('core', 'service-crud.js'), JS('core', 'service-create.js'), JS('live-events.js'), + JS('service-filter.js'), + JS('batch-operations.js'), ], 'features.js': [ JS('logo-customization.js'), @@ -31,6 +35,8 @@ const bundles = { JS('recipes.js'), JS('import-export.js'), JS('error-logs.js'), + JS('container-logs.js'), + JS('snapshot.js'), JS('smart-arr-connect.js'), JS('notification-settings.js'), JS('panel-tabs.js'), @@ -64,6 +70,32 @@ const bundles = { ], }; +function updateInlineScriptCspHash() { + const html = fs.readFileSync(INDEX_HTML, 'utf8'); + const scripts = [...html.matchAll(/ diff --git a/status/js/app-selector.js b/status/js/app-selector.js index 60c7c4a..3855161 100644 --- a/status/js/app-selector.js +++ b/status/js/app-selector.js @@ -1,5 +1,6 @@ // App Selector System (function () { + const errorHandler = new ErrorHandler(); injectModal('app-selector-modal', `

Choose an App

@@ -230,7 +231,7 @@ return true; } } catch (e) { - console.error('Failed to fetch app templates:', e); + errorHandler.logError('[AppSelector] Fetch Templates', e, { function: 'fetchApiTemplates' }); } return false; } @@ -242,7 +243,7 @@ const data = await response.json(); return data; } catch (e) { - console.error('Failed to check port:', e); + errorHandler.logError('[AppSelector] Check Port', e, { function: 'checkPortAvailability' }); return { available: true }; // Assume available on error } } @@ -256,7 +257,7 @@ return data.suggestedPort; } } catch (e) { - console.error('Failed to get suggested port:', e); + errorHandler.logError('[AppSelector] Get Suggested Port', e, { function: 'getSuggestedPort' }); } return basePort; } @@ -842,7 +843,7 @@ throw new Error(result.error || 'Deployment failed'); } } catch (error) { - console.error('Deployment error:', error); + errorHandler.logError('[AppSelector] Deployment', error, { function: 'deploy' }); showNotification( `Failed to deploy ${appTemplate.name}: ${error.message}`, 'error', diff --git a/status/js/batch-operations.js b/status/js/batch-operations.js new file mode 100644 index 0000000..d5d4f93 --- /dev/null +++ b/status/js/batch-operations.js @@ -0,0 +1,138 @@ +// ========== BATCH CONTAINER OPERATIONS ========== +(function() { + const batchBtn = document.getElementById('batch-operations-btn'); + const batchBar = document.getElementById('batch-action-bar'); + const batchCount = document.getElementById('batch-selected-count'); + const startBtn = document.getElementById('batch-start-btn'); + const stopBtn = document.getElementById('batch-stop-btn'); + const restartBtn = document.getElementById('batch-restart-btn'); + const cancelBtn = document.getElementById('batch-cancel-btn'); + + let batchMode = false; + let selectedContainers = new Set(); + + function enterBatchMode() { + batchMode = true; + selectedContainers.clear(); + batchBar.style.display = ''; + batchBtn.textContent = '✓ Exit Batch Mode'; + updateSelectedCount(); + + // Add checkboxes to all cards with containerId + const cards = document.querySelectorAll('#cards .card[data-app]'); + cards.forEach(card => { + const containerId = card.dataset.containerId; + if (!containerId) return; + + // Remove existing checkbox if any + const existing = card.querySelector('.batch-checkbox'); + if (existing) existing.remove(); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'batch-checkbox'; + checkbox.dataset.containerId = containerId; + checkbox.dataset.serviceName = card.querySelector('.name')?.textContent || containerId; + checkbox.style.cssText = 'position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;'; + checkbox.addEventListener('change', (e) => { + e.stopPropagation(); + if (checkbox.checked) { + selectedContainers.add(containerId); + } else { + selectedContainers.delete(containerId); + } + updateSelectedCount(); + }); + card.style.position = 'relative'; + card.insertBefore(checkbox, card.firstChild); + }); + } + + function exitBatchMode() { + batchMode = false; + selectedContainers.clear(); + batchBar.style.display = 'none'; + batchBtn.textContent = '☰ Batch Operations'; + + // Remove all checkboxes + document.querySelectorAll('.batch-checkbox').forEach(cb => cb.remove()); + } + + function updateSelectedCount() { + const count = selectedContainers.size; + batchCount.textContent = `${count} selected`; + startBtn.disabled = count === 0; + stopBtn.disabled = count === 0; + restartBtn.disabled = count === 0; + } + + async function batchAction(action) { + if (selectedContainers.size === 0) return; + + const containers = Array.from(selectedContainers); + const actionLabel = { start: 'Starting', stop: 'Stopping', restart: 'Restarting' }[action]; + + if (!confirm(`${actionLabel} ${containers.length} container(s)? This cannot be undone.`)) return; + + const btns = [startBtn, stopBtn, restartBtn]; + btns.forEach(b => { b.disabled = true; b.textContent = '...'; }); + + let success = 0; + let failed = 0; + const errors = []; + + for (const containerId of containers) { + try { + const res = await fetch(`/api/v1/containers/${encodeURIComponent(containerId)}/${action}`, { + method: 'POST' + }); + if (res.ok) { + success++; + } else { + failed++; + const data = await res.json().catch(() => ({})); + errors.push(`${containerId}: ${data.error || res.statusText}`); + } + } catch (e) { + failed++; + errors.push(`${containerId}: ${e.message}`); + } + } + + // Restore buttons + btns[0].textContent = '▶ Start All'; + btns[1].textContent = '⬛ Stop All'; + btns[2].textContent = '🔄 Restart All'; + updateSelectedCount(); + + // Show results + if (failed === 0) { + if (typeof showNotification === 'function') { + showNotification(`${actionLabel} completed: ${success} container(s)`, 'success'); + } + } else { + if (typeof showNotification === 'function') { + showNotification(`${actionLabel}: ${success} succeeded, ${failed} failed`, 'warning'); + } + console.error('Batch operation errors:', errors); + } + + // Refresh dashboard after a short delay + setTimeout(() => { + if (typeof refreshAll === 'function') refreshAll(); + }, 1500); + } + + batchBtn?.addEventListener('click', () => { + if (batchMode) { + exitBatchMode(); + } else { + enterBatchMode(); + } + }); + + startBtn?.addEventListener('click', () => batchAction('start')); + stopBtn?.addEventListener('click', () => batchAction('stop')); + restartBtn?.addEventListener('click', () => batchAction('restart')); + cancelBtn?.addEventListener('click', exitBatchMode); +})(); diff --git a/status/js/container-logs.js b/status/js/container-logs.js new file mode 100644 index 0000000..88bac91 --- /dev/null +++ b/status/js/container-logs.js @@ -0,0 +1,429 @@ +// ========== CONTAINER LOG VIEWER ========== +(function() { + // Inject modal HTML + injectModal('container-logs-modal', `
+
+
+
+

📜 Container Logs

+ +
+
+ + + + + + + + +
+
+ +
+ Image: - + Status: - + Created: - +
+ +
+
Select a container to view logs
+
+ + + + +
+
`); + + const modal = document.getElementById('container-logs-modal'); + const containerSelect = document.getElementById('cl-container-select'); + const logContent = document.getElementById('cl-log-content'); + const logSearch = document.getElementById('cl-log-search'); + const logTail = document.getElementById('cl-log-tail'); + const refreshBtn = document.getElementById('cl-refresh'); + const streamBtn = document.getElementById('cl-stream'); + const downloadBtn = document.getElementById('cl-download'); + const clearSearchBtn = document.getElementById('cl-clear-search'); + const closeBtn = document.getElementById('cl-close'); + const closeBtn2 = document.getElementById('cl-close-btn'); + const streamStatus = document.getElementById('cl-stream-status'); + const streamIndicator = document.getElementById('cl-stream-indicator'); + const streamText = document.getElementById('cl-stream-text'); + const lineCount = document.getElementById('cl-line-count'); + const filterCount = document.getElementById('cl-filter-count'); + + // Container info elements + const imageEl = document.getElementById('cl-image'); + const statusEl = document.getElementById('cl-status'); + const createdEl = document.getElementById('cl-created'); + + let currentContainerId = null; + let currentLogs = []; + let filteredLogs = []; + let eventSource = null; + let isStreaming = false; + let searchTimeout = null; + + // Format date + function formatDate(dateStr) { + if (!dateStr) return '-'; + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleString(); + } + + // Escape HTML + function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + // Format log entry for display + function formatLogEntry(log, index) { + const streamClass = log.stream === 'stderr' ? 'log-stderr' : 'log-stdout'; + const streamIcon = log.stream === 'stderr' ? '⚠️' : '📤'; + return ` +
+ ${index + 1} + ${streamIcon} + ${escapeHtml(log.text)} +
+ `; + } + + // Render logs to the content area + function renderLogs(logs, searchTerm = '') { + if (!logs || logs.length === 0) { + logContent.innerHTML = '
No logs available
'; + lineCount.textContent = '0 lines'; + filterCount.textContent = '0 filtered'; + return; + } + + currentLogs = logs; + filteredLogs = searchTerm ? logs.filter(log => + log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase()) + ) : logs; + + lineCount.textContent = `${logs.length} lines`; + filterCount.textContent = searchTerm ? `${filteredLogs.length} of ${logs.length} shown` : `${logs.length} shown`; + + if (filteredLogs.length === 0) { + logContent.innerHTML = `
No logs match "${escapeHtml(searchTerm)}"
`; + return; + } + + logContent.innerHTML = filteredLogs.map((log, i) => formatLogEntry(log, i)).join(''); + + // Scroll to bottom + logContent.scrollTop = logContent.scrollHeight; + } + + // Load container list + async function loadContainers() { + try { + const data = await getJSON('/api/v1/logs/containers'); + const containers = data.containers || []; + + // Store current selection + const currentVal = containerSelect.value; + + containerSelect.innerHTML = ''; + + containers.forEach(c => { + const option = document.createElement('option'); + option.value = c.id; + option.textContent = `${c.name} (${c.image.split(':')[0]}) - ${c.status}`; + option.dataset.name = c.name; + option.dataset.image = c.image; + option.dataset.status = c.status; + option.dataset.created = c.created; + containerSelect.appendChild(option); + }); + + // Restore selection if still valid + if (currentVal && containerSelect.querySelector(`option[value="${currentVal}"]`)) { + containerSelect.value = currentVal; + loadContainerInfo(currentVal); + } + } catch (err) { + console.error('Failed to load containers:', err); + } + } + + // Load container info + function loadContainerInfo(containerId) { + const option = containerSelect.querySelector(`option[value="${containerId}"]`); + if (option) { + imageEl.textContent = option.dataset.image || '-'; + statusEl.textContent = option.dataset.status || '-'; + statusEl.style.color = option.dataset.status === 'running' ? 'var(--ok-fg, #4ade80)' : 'var(--bad-fg, #ef4444)'; + createdEl.textContent = formatDate(option.dataset.created); + } + } + + // Load logs for selected container + async function loadLogs() { + const containerId = containerSelect.value; + if (!containerId) { + logContent.innerHTML = '
Select a container to view logs
'; + return; + } + + // Stop any existing stream + stopStream(); + + currentContainerId = containerId; + loadContainerInfo(containerId); + + const tail = logTail.value; + const searchTerm = logSearch.value.trim(); + + logContent.innerHTML = '
Loading logs...
'; + + try { + const url = `/api/v1/logs/container/${containerId}${tail !== 'all' ? `?tail=${tail}` : ''}`; + const data = await getJSON(url); + + if (data.logs && data.logs.length > 0) { + renderLogs(data.logs, searchTerm); + } else { + logContent.innerHTML = '
No logs found for this container
'; + lineCount.textContent = '0 lines'; + filterCount.textContent = '0 filtered'; + } + } catch (err) { + logContent.innerHTML = `
Error loading logs: ${escapeHtml(err.message)}
`; + } + } + + // Start streaming logs + function startStream() { + const containerId = containerSelect.value; + if (!containerId) return; + + // Stop any existing stream + stopStream(); + + isStreaming = true; + streamBtn.textContent = '⏹ Stop'; + streamStatus.style.display = 'flex'; + streamIndicator.textContent = '🟢'; + streamText.textContent = 'Connecting...'; + + const url = `/api/v1/logs/stream/${containerId}`; + eventSource = new EventSource(url); + + eventSource.onopen = () => { + streamIndicator.textContent = '🟢'; + streamText.textContent = 'Connected - streaming logs'; + }; + + eventSource.onmessage = (event) => { + try { + const log = JSON.parse(event.data); + + if (log.error) { + streamIndicator.textContent = '🔴'; + streamText.textContent = `Error: ${log.error}`; + return; + } + + // Add to current logs + currentLogs.push(log); + filteredLogs.push(log); + + // Update counts + lineCount.textContent = `${currentLogs.length} lines`; + filterCount.textContent = `${filteredLogs.length} shown`; + + // Append new log entry + const searchTerm = logSearch.value.trim(); + if (!searchTerm || (log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase()))) { + const entry = document.createElement('div'); + entry.innerHTML = formatLogEntry(log, filteredLogs.length - 1); + const entryDiv = entry.firstElementChild; + entryDiv.style.background = '#1a3a1a'; + logContent.appendChild(entryDiv); + + // Auto-scroll to bottom + logContent.scrollTop = logContent.scrollHeight; + } + } catch (e) { + console.error('Error parsing log:', e); + } + }; + + eventSource.onerror = () => { + streamIndicator.textContent = '🔴'; + streamText.textContent = 'Disconnected'; + isStreaming = false; + streamBtn.textContent = '▶ Stream'; + }; + + // Store the EventSource for cleanup + modal._eventSource = eventSource; + } + + // Stop streaming logs + function stopStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (modal._eventSource) { + modal._eventSource.close(); + modal._eventSource = null; + } + isStreaming = false; + streamBtn.textContent = '▶ Stream'; + streamStatus.style.display = 'none'; + } + + // Download logs as file + function downloadLogs() { + if (!currentLogs || currentLogs.length === 0) { + showNotification('No logs to download', 'error'); + return; + } + + const containerName = containerSelect.querySelector(`option[value="${currentContainerId}"]`)?.dataset.name || currentContainerId; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${containerName}-logs-${timestamp}.txt`; + + const content = currentLogs.map(log => { + const timestamp = log.timestamp || ''; + const stream = log.stream === 'stderr' ? '[ERR]' : '[OUT]'; + return `${timestamp ? timestamp + ' ' : ''}${stream} ${log.text}`; + }).join('\n'); + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showNotification(`Downloaded ${currentLogs.length} log lines`, 'success'); + } + + // Event listeners + containerSelect?.addEventListener('change', () => { + loadLogs(); + }); + + logTail?.addEventListener('change', () => { + loadLogs(); + }); + + refreshBtn?.addEventListener('click', () => { + loadLogs(); + }); + + streamBtn?.addEventListener('click', () => { + if (isStreaming) { + stopStream(); + } else { + startStream(); + } + }); + + downloadBtn?.addEventListener('click', () => { + downloadLogs(); + }); + + clearSearchBtn?.addEventListener('click', () => { + logSearch.value = ''; + renderLogs(currentLogs, ''); + }); + + logSearch?.addEventListener('input', () => { + // Debounce search + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + renderLogs(currentLogs, logSearch.value.trim()); + }, 300); + }); + + logSearch?.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + logSearch.value = ''; + renderLogs(currentLogs, ''); + } + }); + + // Open modal + const openBtn = document.getElementById('view-container-logs'); + openBtn?.addEventListener('click', () => { + modal.classList.add('show'); + loadContainers(); + }); + + // Close modal handlers + function closeModal() { + stopStream(); + modal.classList.remove('show'); + } + + closeBtn?.addEventListener('click', closeModal); + closeBtn2?.addEventListener('click', closeModal); + + // Close on escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.classList.contains('show')) { + closeModal(); + } + }); + + // Wire modal (close on backdrop click) + wireModal(modal, null, closeModal); + + // Expose for use by service card buttons (grid.js calls openContainerLogsModal) + window.openContainerLogsModal = function(containerId, containerName) { + modal.classList.add('show'); + loadContainers().then(() => { + // Try to find and select the container + const option = Array.from(containerSelect.options).find(opt => + opt.value === containerId || opt.dataset.name === containerName + ); + if (option) { + containerSelect.value = option.value; + loadContainerInfo(option.value); + loadLogs(); + } else if (containerId) { + // If container not found in list but we have an ID, try loading directly + currentContainerId = containerId; + imageEl.textContent = containerName || containerId; + statusEl.textContent = '-'; + createdEl.textContent = '-'; + loadLogs(); + } else { + // No container ID, just show modal with container list + logContent.innerHTML = '
Select a container to view logs
'; + } + }); + }; +})(); diff --git a/status/js/dns-template-selector.js b/status/js/dns-template-selector.js index 845af68..d0f03a8 100644 --- a/status/js/dns-template-selector.js +++ b/status/js/dns-template-selector.js @@ -6,12 +6,16 @@ (function(window) { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) console.log(...args); + }; + class DnsTemplateSelector { constructor(progressTracker) { this.progressTracker = progressTracker; this.modal = null; this.onTemplateSelected = null; - console.log('[DnsTemplateSelector] Module loaded'); + debug('[DnsTemplateSelector] Module loaded'); } /** @@ -216,7 +220,7 @@ * @private */ handleTemplateSelection(template) { - console.log(`[DnsTemplateSelector] Template selected: ${template.id}`); + debug(`[DnsTemplateSelector] Template selected: ${template.id}`); // Close modal this.close(); @@ -235,7 +239,7 @@ * @private */ handleSetupLater() { - console.log('[DnsTemplateSelector] DNS setup deferred'); + debug('[DnsTemplateSelector] DNS setup deferred'); // Mark as deferred in progress tracker if (this.progressTracker) { @@ -316,6 +320,6 @@ } window.DnsTemplateSelector = DnsTemplateSelector; - console.log('[DnsTemplateSelector] Module loaded'); + debug('[DnsTemplateSelector] Module loaded'); })(window); diff --git a/status/js/globals.js b/status/js/globals.js index 8153266..31d4642 100644 --- a/status/js/globals.js +++ b/status/js/globals.js @@ -24,6 +24,9 @@ const DC = { }, }; +// Error handler for tracking issues +const errorHandler = new ErrorHandler(); + // ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) ===== // Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null'); @@ -160,7 +163,7 @@ async function getCSRFToken() { csrfToken = data.token; return csrfToken; } catch (error) { - console.error('Failed to get CSRF token:', error); + errorHandler.logError('[CSRF] Get Token', error, { function: 'getCSRFToken' }); throw error; } } @@ -185,7 +188,7 @@ async function secureFetch(url, options = {}) { 'X-CSRF-Token': token }; } catch (error) { - console.error('Failed to add CSRF token to request:', error); + errorHandler.logError('[CSRF] Add to Request', error, { function: 'secureFetch' }); } } diff --git a/status/js/keyboard-shortcuts.js b/status/js/keyboard-shortcuts.js index 2080750..091ead3 100644 --- a/status/js/keyboard-shortcuts.js +++ b/status/js/keyboard-shortcuts.js @@ -6,6 +6,10 @@ (function() { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + // All modal selectors that can be closed with Escape const MODAL_SELECTORS = [ '#app-selector-modal', @@ -39,9 +43,9 @@ // 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'); + debug('[Keyboard Shortcuts] Initialized'); + debug('[Keyboard Shortcuts] Press Ctrl+K to open quick search'); + debug('[Keyboard Shortcuts] Press Escape to close modals'); } catch (e) { console.warn('[Keyboard Shortcuts] Failed to initialize:', e.message); } @@ -599,7 +603,7 @@ } break; default: - console.log('[Keyboard Shortcuts] Unknown action:', action); + debug('[Keyboard Shortcuts] Unknown action:', action); } } catch (e) { console.warn('[Keyboard Shortcuts] Error executing action:', e.message); diff --git a/status/js/live-events.js b/status/js/live-events.js index 7a54ebd..0eb8e75 100644 --- a/status/js/live-events.js +++ b/status/js/live-events.js @@ -11,7 +11,7 @@ es.addEventListener('connected', () => { reconnectDelay = 1000; // reset backoff - console.log('[SSE] Connected to event stream'); + debug('[SSE] Connected to event stream'); }); // Health status changes → update card dots/badges in real time diff --git a/status/js/notification-settings.js b/status/js/notification-settings.js index 636630e..8d41809 100644 --- a/status/js/notification-settings.js +++ b/status/js/notification-settings.js @@ -1,5 +1,6 @@ // ========== NOTIFICATION SETTINGS ========== (function() { + const errorHandler = new ErrorHandler(); // Inject modal HTML injectModal('notifications-modal', `
@@ -255,7 +256,7 @@ document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false; } } catch (error) { - console.error('Failed to load notification config:', error); + errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' }); } } @@ -289,7 +290,7 @@ container.innerHTML = '
No notifications yet
'; } } catch (error) { - console.error('Failed to load notification history:', error); + errorHandler.logError('[Notifications] Load History', error, { function: 'loadHistory' }); } } diff --git a/status/js/onboarding.js b/status/js/onboarding.js index f690ade..6a793bd 100644 --- a/status/js/onboarding.js +++ b/status/js/onboarding.js @@ -9,6 +9,12 @@ (function() { 'use strict'; + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { + console.log(...args); + } + }; + let progressTracker; let themeAdapter; let tourManager; @@ -20,7 +26,7 @@ */ async function initializeOnboarding() { try { - console.log('[Onboarding] Initializing system...'); + debug('[Onboarding] Initializing system...'); if (window.__dashcaddySiteConfigLoaded) { try { @@ -30,27 +36,27 @@ // Initialize Error Handler first errorHandler = new ErrorHandler(); - console.log('[Onboarding] Error Handler initialized'); + debug('[Onboarding] Error Handler initialized'); // Initialize Progress Tracker progressTracker = new ProgressTracker('dashcaddy_onboarding'); - console.log('[Onboarding] Progress Tracker initialized'); + debug('[Onboarding] Progress Tracker initialized'); // Initialize Theme Adapter themeAdapter = new ThemeAdapter(); - console.log('[Onboarding] Theme Adapter initialized'); + debug('[Onboarding] Theme Adapter initialized'); // Initialize DNS Template Selector dnsTemplateSelector = new DnsTemplateSelector(progressTracker); - console.log('[Onboarding] DNS Template Selector initialized'); + debug('[Onboarding] DNS Template Selector initialized'); // Initialize Tour Manager tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); - console.log('[Onboarding] Tour Manager initialized'); + debug('[Onboarding] Tour Manager initialized'); // Check if tour should auto-start if (tourManager.shouldAutoStart()) { - console.log('[Onboarding] Auto-starting tour for first-time install'); + debug('[Onboarding] Auto-starting tour for first-time install'); await progressTracker.markInstallOnboardingCompleted(); // Wait a bit for page to fully load setTimeout(() => { @@ -59,11 +65,11 @@ } else { const tourCompleted = progressTracker.isTourCompleted(); const currentStep = progressTracker.getCurrentStep(); - console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); + debug(`[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'); + debug('[Onboarding] Tour in progress, can be resumed manually'); } } @@ -81,13 +87,10 @@ getErrorStats: () => errorHandler.getStatistics() }; - console.log('[Onboarding] System initialized successfully'); + debug('[Onboarding] System initialized successfully'); } catch (error) { - console.error('[Onboarding] Initialization error:', error); - - // Use error handler if available if (errorHandler) { - errorHandler.logError('Initialization', error); + errorHandler.logError('[Onboarding] Initialization', error); } // Graceful degradation - don't break the dashboard @@ -104,10 +107,12 @@ const clickHandler = () => { if (tourManager) { - console.log('[Onboarding] Starting tour via button click'); + debug('[Onboarding] Starting tour via button click'); tourManager.restartTour(); } else { - console.error('[Onboarding] Tour manager not initialized'); + if (errorHandler) { + errorHandler.logError('[Onboarding] Tour Manager Not Initialized', new Error('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'); } }; @@ -157,7 +162,6 @@ 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 { @@ -179,6 +183,6 @@ waitForDriver(); } - console.log('[Onboarding] System loaded'); + debug('[Onboarding] System loaded'); })(); diff --git a/status/js/progress-tracker.js b/status/js/progress-tracker.js index 9fb2757..8982406 100644 --- a/status/js/progress-tracker.js +++ b/status/js/progress-tracker.js @@ -18,6 +18,12 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + /** * ProgressTracker class * Manages persistent storage of onboarding progress @@ -68,7 +74,7 @@ const data = localStorage.getItem(this.storageKey); return data ? JSON.parse(data) : null; } catch (error) { - console.error('[ProgressTracker] Error reading from storage:', error); + errorHandler.logError('[ProgressTracker] Read Storage', error, { function: '_getStorage' }); return null; } } @@ -82,7 +88,7 @@ try { localStorage.setItem(this.storageKey, JSON.stringify(state)); } catch (error) { - console.error('[ProgressTracker] Error writing to storage:', error); + errorHandler.logError('[ProgressTracker] Write Storage', error, { function: '_setStorage' }); // Handle quota exceeded or storage unavailable // Fall back to session storage or in-memory storage this._handleStorageError(error); @@ -100,7 +106,7 @@ 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); + errorHandler.logError('[ProgressTracker] Session Storage Unavailable', sessionError, { function: '_handleStorageError' }); // Could implement in-memory fallback here if needed } } @@ -186,7 +192,7 @@ body: JSON.stringify({ onboardingCompleted: true }) }); } catch (error) { - console.error('[ProgressTracker] Failed to persist install onboarding state:', error); + errorHandler.logError('[ProgressTracker] Persist Install Onboarding', error, { function: 'markInstallOnboardingCompleted' }); } } @@ -308,6 +314,6 @@ // Export to global scope window.ProgressTracker = ProgressTracker; - console.log('[ProgressTracker] Module loaded'); + debug('[ProgressTracker] Module loaded'); })(window); diff --git a/status/js/service-credentials.js b/status/js/service-credentials.js index e5ddb06..0186b74 100644 --- a/status/js/service-credentials.js +++ b/status/js/service-credentials.js @@ -1,5 +1,6 @@ // ===== SERVICE CREDENTIALS ===== (function() { + const errorHandler = new ErrorHandler(); injectModal('folder-browser-modal', `

📂 Browse for Media Folders

@@ -433,7 +434,7 @@ await loadServiceCreds(currentService); } catch (e) { - console.error('Failed to save credentials:', e); + errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' }); showError('Failed to save: ' + (e.message || 'Unknown error')); } saveBtn.textContent = 'Save'; @@ -460,7 +461,7 @@ if (btn) btn.classList.remove('has-creds'); await loadServiceCreds(currentService); } catch (e) { - console.error('Failed to clear credentials:', e); + errorHandler.logError('[ServiceCredentials] Clear', e, { function: 'clearCredentials' }); showError('Failed to clear: ' + (e.message || 'Unknown error')); } }); diff --git a/status/js/service-filter.js b/status/js/service-filter.js new file mode 100644 index 0000000..3ca7125 --- /dev/null +++ b/status/js/service-filter.js @@ -0,0 +1,57 @@ +// ========== SERVICE FILTER ========== +(function() { + const searchInput = document.getElementById('service-filter-search'); + const statusSelect = document.getElementById('service-filter-status'); + const countSpan = document.getElementById('service-filter-count'); + + function updateFilter() { + const query = searchInput.value.toLowerCase().trim(); + const statusFilter = statusSelect.value; // 'all', 'on', or 'off' + + const cards = document.querySelectorAll('#cards .card'); + let visibleCount = 0; + + cards.forEach(card => { + const name = card.querySelector('.name')?.textContent?.toLowerCase() || ''; + const app = card.dataset.app?.toLowerCase() || ''; + const status = card.dataset.status || 'off'; // 'on' or 'off' + + const matchesSearch = !query || name.includes(query) || app.includes(query); + const matchesStatus = statusFilter === 'all' || status === statusFilter; + + if (matchesSearch && matchesStatus) { + card.style.display = ''; + visibleCount++; + } else { + card.style.display = 'none'; + } + }); + + if (countSpan) { + const total = cards.length; + countSpan.textContent = `${visibleCount} of ${total} services`; + } + } + + // Debounce helper + function debounce(fn, delay) { + let timeout; + return function(...args) { + clearTimeout(timeout); + timeout = setTimeout(() => fn.apply(this, args), delay); + }; + } + + searchInput?.addEventListener('input', debounce(updateFilter, 200)); + statusSelect?.addEventListener('change', updateFilter); + + // Initial count on page load + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => setTimeout(updateFilter, 500)); + } else { + setTimeout(updateFilter, 500); + } + + // Expose for external triggers + window.refreshServiceFilter = updateFilter; +})(); diff --git a/status/js/setup-wizard.js b/status/js/setup-wizard.js index 15a396d..c6dd957 100644 --- a/status/js/setup-wizard.js +++ b/status/js/setup-wizard.js @@ -1,4 +1,6 @@ // Shared timezone utility — used by setup wizard and settings modal +const errorHandler = new ErrorHandler(); + window.populateTimezoneSelect = function(selectEl, selectedTz) { const timezones = Intl.supportedValuesOf('timeZone'); const detected = selectedTz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; @@ -150,11 +152,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) { await response.json(); return true; } else { - console.error('Failed to save config to server:', response.status); + errorHandler.logError('[SetupWizard] Save Config', new Error(`Server returned ${response.status}`), { function: 'saveConfigToServer' }); return false; } } catch (error) { - console.error('Error saving config to server:', error); + errorHandler.logError('[SetupWizard] Save Config', error, { function: 'saveConfigToServer' }); return false; } } diff --git a/status/js/snapshot.js b/status/js/snapshot.js new file mode 100644 index 0000000..52e05a7 --- /dev/null +++ b/status/js/snapshot.js @@ -0,0 +1,180 @@ +// ========== CONTAINER SNAPSHOT / CHECKPOINT ========== +(function() { + // Inject modal HTML + injectModal('snapshot-modal', `
+
+

💾 Container Snapshots

+ + +
+ + +
+ + + +
+ + +
+ +
+
+ + +
+
+ +
+ +
+
+ + + + +
+
`); + + const modal = document.getElementById('snapshot-modal'); + const openBtn = document.getElementById('snapshot-btn'); + const closeBtn = document.getElementById('snapshot-close'); + const containerSelect = document.getElementById('snapshot-container-select'); + const detailsDiv = document.getElementById('snapshot-details'); + const createBtn = document.getElementById('snapshot-create-btn'); + const createStatus = document.getElementById('snapshot-create-status'); + + let currentContainerId = null; + + async function loadContainers() { + try { + const res = await fetch('/api/v1/containers'); + const data = await res.json(); + if (!data.success || !data.containers) return; + + containerSelect.innerHTML = ''; + for (const c of data.containers) { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = `${c.name || c.id} (${c.image || 'unknown'})`; + opt.dataset.name = c.name; + opt.dataset.image = c.image; + opt.dataset.status = c.status; + opt.dataset.created = c.created; + containerSelect.appendChild(opt); + } + } catch (e) { + console.error('Failed to load containers:', e); + } + } + + function showContainerDetails(opt) { + if (!opt || !opt.value) { + detailsDiv.style.display = 'none'; + currentContainerId = null; + return; + } + currentContainerId = opt.value; + document.getElementById('snapshot-image').textContent = opt.dataset.image || '-'; + document.getElementById('snapshot-status').textContent = opt.dataset.status || '-'; + document.getElementById('snapshot-created').textContent = opt.dataset.created ? new Date(opt.dataset.created * 1000).toLocaleString() : '-'; + document.getElementById('snapshot-id').textContent = opt.value.substring(0, 12); + detailsDiv.style.display = ''; + } + + async function createSnapshot() { + if (!currentContainerId) { + createStatus.textContent = 'Please select a container first'; + createStatus.style.color = 'var(--bad-fg)'; + return; + } + const name = document.getElementById('snapshot-name').value.trim(); + if (!name) { + createStatus.textContent = 'Please enter a snapshot name'; + createStatus.style.color = 'var(--bad-fg)'; + return; + } + const leaveRunning = document.getElementById('snapshot-leave-running').checked; + + createBtn.disabled = true; + createBtn.textContent = 'Creating...'; + createStatus.textContent = ''; + + try { + const res = await fetch(`/api/v1/containers/${encodeURIComponent(currentContainerId)}/checkpoint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, leaveRunning }) + }); + const data = await res.json(); + if (data.success) { + createStatus.textContent = `✓ Snapshot "${name}" created successfully`; + createStatus.style.color = 'var(--ok-fg)'; + document.getElementById('snapshot-name').value = ''; + } else { + createStatus.textContent = `✗ Failed: ${data.error || 'Unknown error'}`; + createStatus.style.color = 'var(--bad-fg)'; + } + } catch (e) { + createStatus.textContent = `✗ Error: ${e.message}`; + createStatus.style.color = 'var(--bad-fg)'; + } finally { + createBtn.disabled = false; + createBtn.textContent = '💾 Create Snapshot'; + } + } + + function openModal() { + modal.classList.add('show'); + loadContainers(); + } + + function closeModal() { + modal.classList.remove('show'); + detailsDiv.style.display = 'none'; + currentContainerId = null; + containerSelect.selectedIndex = 0; + } + + openBtn?.addEventListener('click', openModal); + closeBtn?.addEventListener('click', closeModal); + wireModal(modal, closeBtn); + + containerSelect?.addEventListener('change', (e) => { + const opt = containerSelect.options[containerSelect.selectedIndex]; + showContainerDetails(opt); + }); + + createBtn?.addEventListener('click', createSnapshot); + + // Tab switching + modal?.querySelectorAll('.panel-tab').forEach(tab => { + tab.addEventListener('click', () => { + modal.querySelectorAll('.panel-tab').forEach(t => t.classList.remove('active')); + modal.querySelectorAll('.panel-section').forEach(s => s.classList.remove('active')); + tab.classList.add('active'); + modal.querySelector(`#${tab.dataset.panel}`).classList.add('active'); + }); + }); +})(); diff --git a/status/js/theme-adapter.js b/status/js/theme-adapter.js index e79ae88..b0afc40 100644 --- a/status/js/theme-adapter.js +++ b/status/js/theme-adapter.js @@ -7,6 +7,12 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + /** * Theme configuration mapping for Driver.js * Maps dashboard themes to Driver.js styling @@ -170,7 +176,7 @@ attributeFilter: ['class'] }); - console.log('[ThemeAdapter] Theme change listener initialized'); + debug('[ThemeAdapter] Theme change listener initialized'); } /** @@ -180,13 +186,13 @@ * @param {string} oldTheme - Old theme name */ _notifyThemeChange(newTheme, oldTheme) { - console.log(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); + debug(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); this.themeChangeCallbacks.forEach(callback => { try { callback(newTheme, oldTheme); } catch (error) { - console.error('[ThemeAdapter] Error in theme change callback:', error); + errorHandler.logError('[ThemeAdapter] Theme Change Callback', error, { function: '_notifyThemeChange' }); } }); } @@ -207,7 +213,7 @@ // 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); + debug('[ThemeAdapter] Theme applied to driver:', this.currentTheme); } /** @@ -302,6 +308,6 @@ // Export to global scope window.ThemeAdapter = ThemeAdapter; - console.log('[ThemeAdapter] Module loaded'); + debug('[ThemeAdapter] Module loaded'); })(window); diff --git a/status/js/tooltip-definitions.js b/status/js/tooltip-definitions.js index 970b583..215bd89 100644 --- a/status/js/tooltip-definitions.js +++ b/status/js/tooltip-definitions.js @@ -6,6 +6,12 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + /** * Validate a tooltip definition * @param {Object} tooltip - The tooltip definition to validate @@ -147,7 +153,7 @@ `${e.tooltip}: ${e.errors.join(', ')}` ).join('\n'); - console.error('[TooltipDefinitions] Validation errors:', errorMessages); + errorHandler.logError('[TooltipDefinitions] Validation', errorMessages, { function: 'validateTooltip' }); throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); } } @@ -160,7 +166,7 @@ TooltipError }; - console.log('[TooltipDefinitions] Validation module loaded'); + debug('[TooltipDefinitions] Validation module loaded'); })(window); @@ -489,7 +495,7 @@ function getActiveTooltips() { try { return tooltip.condition(); } catch (error) { - console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); + errorHandler.logError('[TooltipDefinitions] Condition Eval', error, { function: 'evaluateCondition', tooltipId: tooltip.id }); return false; } } @@ -534,5 +540,5 @@ window.TooltipDefinitions = { getNewFeatureTooltips }; -console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); +debug('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); diff --git a/status/js/totp-settings.js b/status/js/totp-settings.js index c326177..b4e54e5 100644 --- a/status/js/totp-settings.js +++ b/status/js/totp-settings.js @@ -1,5 +1,6 @@ // ===== TOTP SETTINGS ===== (function() { + const errorHandler = new ErrorHandler(); injectModal('totp-settings-modal', `

Authentication Settings

@@ -185,7 +186,7 @@ document.getElementById('totp-setup-code').focus(); } } catch (e) { - console.error('TOTP setup failed:', e); + errorHandler.logError('[TOTP] Setup Failed', e, { function: 'setupTOTP' }); } }); @@ -274,7 +275,7 @@ }); loadTotpSettings(); // Refresh modal + card (handles "never" disabling TOTP) } catch (err) { - console.error('Failed to update session duration:', err); + errorHandler.logError('[TOTP] Update Session Duration', err, { function: 'updateSessionDuration' }); } }); @@ -290,7 +291,7 @@ const data = await res.json(); if (data.success) loadTotpSettings(); } catch (e) { - console.error('Failed to disable TOTP:', e); + errorHandler.logError('[TOTP] Disable Failed', e, { function: 'disableTOTP' }); } }); @@ -322,7 +323,7 @@ const active = data.config.enabled && data.config.isSetUp; updateAuthCard(active, data.config.sessionDuration); } - } catch (e) { console.error('[AuthCard] Failed to update:', e); } + } catch (e) { errorHandler.logError('[TOTP] AuthCard Update', e, { function: 'authCardUpdate' }); } })(); })(); diff --git a/status/js/tour-manager.js b/status/js/tour-manager.js index 7b60d2a..c145d7f 100644 --- a/status/js/tour-manager.js +++ b/status/js/tour-manager.js @@ -6,6 +6,12 @@ (function(window) { 'use strict'; + const errorHandler = new ErrorHandler(); + + const debug = (...args) => { + if (window.DASHCADDY_DEBUG) { console.log(...args); } + }; + class TourManager { constructor(progressTracker, themeAdapter, dnsTemplateSelector) { this.progressTracker = progressTracker; @@ -26,7 +32,7 @@ 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); + errorHandler.logError('[TourManager] Driver.js Not Loaded', new Error('Driver.js not loaded or invalid'), { windowDriver: typeof window.driver }); return false; } @@ -92,7 +98,7 @@ const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); if (activeTooltips.length === 0) { - console.log('[TourManager] No tooltips to show'); + debug('[TourManager] No tooltips to show'); this.progressTracker.markTourCompleted(); return; } @@ -131,7 +137,7 @@ // Add custom handlers for DNS tooltip if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { step.popover.onSetupNowClick = () => { - console.log('[TourManager] Opening DNS template selector'); + debug('[TourManager] Opening DNS template selector'); this.dnsTemplateSelector.showTemplateSelector(); // Mark tooltip as completed and move to next this.progressTracker.markTooltipCompleted(tooltip.id); @@ -141,7 +147,7 @@ }; step.popover.onLaterClick = () => { - console.log('[TourManager] DNS setup deferred'); + debug('[TourManager] DNS setup deferred'); this.progressTracker.markDnsSetupDeferred(); // Mark tooltip as completed and move to next this.progressTracker.markTooltipCompleted(tooltip.id); @@ -198,7 +204,7 @@ async showTooltip(tooltipId) { const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); if (!tooltip) { - console.error(`[TourManager] Tooltip not found: ${tooltipId}`); + errorHandler.logError('[TourManager] Tooltip Not Found', new Error(`Tooltip not found: ${tooltipId}`), { tooltipId }); return; } @@ -232,11 +238,11 @@ const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); if (newFeatureTooltips.length === 0) { - console.log('[TourManager] No new features to show'); + debug('[TourManager] No new features to show'); return; } - console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); + debug(`[TourManager] Showing ${newFeatureTooltips.length} new features`); // Convert to Driver.js steps const steps = newFeatureTooltips.map((tooltip, index) => { @@ -280,7 +286,7 @@ clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (this.isActive && this.driver) { - console.log('[TourManager] Window resized, repositioning tooltip'); + debug('[TourManager] Window resized, repositioning tooltip'); this.driver.refresh(); } }, 150); // Debounce for 150ms @@ -289,7 +295,7 @@ // Layout change handler (for theme changes, DOM mutations) this.layoutChangeHandler = () => { if (this.isActive && this.driver) { - console.log('[TourManager] Layout changed, repositioning tooltip'); + debug('[TourManager] Layout changed, repositioning tooltip'); // Small delay to allow layout to settle setTimeout(() => { if (this.driver) { @@ -347,7 +353,7 @@ onTourComplete() { this.progressTracker.markTourCompleted(); this.isActive = false; - console.log('[TourManager] Tour completed'); + debug('[TourManager] Tour completed'); } /** @@ -355,12 +361,12 @@ */ onTourSkip() { // Save current progress but don't mark as completed - console.log('[TourManager] Tour skipped'); + debug('[TourManager] Tour skipped'); this.isActive = false; } } window.TourManager = TourManager; - console.log('[TourManager] Module loaded'); + debug('[TourManager] Module loaded'); })(window); diff --git a/status/js/update-management.js b/status/js/update-management.js index 03d5f5c..51adf08 100644 --- a/status/js/update-management.js +++ b/status/js/update-management.js @@ -397,7 +397,7 @@ } async function dcApplyUpdate() { - if (!confirm('Apply DashCaddy update? The API container will restart.')) return; + if (!confirm('Apply DashCaddy update? The API container will restart.')) return false; dcApplyBtn.textContent = 'Updating...'; dcApplyBtn.disabled = true; dcShowStatus('Downloading and applying update...', 'info'); @@ -409,6 +409,7 @@ dcApplyBtn.textContent = 'Applied!'; // Remove notification dots document.querySelectorAll('.update-dot').forEach(d => d.remove()); + return true; } else { throw new Error(data.error || 'Update failed'); } @@ -416,6 +417,7 @@ dcShowStatus('Update failed: ' + e.message, 'error'); dcApplyBtn.textContent = 'Update Now'; dcApplyBtn.disabled = false; + throw e; } } @@ -487,7 +489,7 @@ } dcCheckBtn?.addEventListener('click', () => dcCheckForUpdate(false)); - dcApplyBtn?.addEventListener('click', dcApplyUpdate); + dcApplyBtn?.addEventListener('click', () => dcApplyUpdate().catch(() => {})); dcRollbackBtn?.addEventListener('click', dcShowRollback); checkBtn?.addEventListener('click', checkForUpdates); @@ -506,6 +508,9 @@ if (!dcLastCheck) dcCheckForUpdate(true); }); + window.dcApplyUpdate = dcApplyUpdate; + window.dcCheckForUpdate = dcCheckForUpdate; + // Non-blocking check on page load — just adds notification dot if update available setTimeout(() => dcCheckForUpdate(true), 5000); })(); diff --git a/status/js/weather.js b/status/js/weather.js index b439a2d..1d235a6 100644 --- a/status/js/weather.js +++ b/status/js/weather.js @@ -1,5 +1,6 @@ // ========== WEATHER WIDGET ========== (function() { + const errorHandler = new ErrorHandler(); // Inject modal HTML injectModal('weather-modal', `

Weather Settings

@@ -166,7 +167,7 @@ weatherWidget.icon.innerHTML = `${escapeHtml(weather.icon)}`; } } catch (error) { - console.error('Weather update error:', error); + errorHandler.logError('[Weather] Update Error', error, { function: 'updateWeather' }); weatherWidget.location.textContent = 'Weather Error'; weatherWidget.temp.textContent = 'Error'; weatherWidget.condition.textContent = 'Failed to load';