// ===== 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) =====
// Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API
const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null');
const SITE = {
tld: (_cachedCfg && _cachedCfg.tld) || '.home',
dnsIp: '',
dnsPort: DC.DEFAULTS.DNS_PORT,
dnsServers: {},
configurationType: (_cachedCfg && _cachedCfg.configurationType) || 'homelab',
domain: (_cachedCfg && _cachedCfg.domain) || '',
defaults: (_cachedCfg && _cachedCfg.defaults) || {},
routingMode: (_cachedCfg && _cachedCfg.routingMode) || 'subdomain',
onboardingCompleted: false
};
window.__dashcaddySiteConfigLoaded = (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 && typeof c.dnsServers === 'object') {
for (const [k, v] of Object.entries(c.dnsServers)) {
if (k !== '__proto__' && k !== 'constructor' && k !== 'prototype') SITE.dnsServers[k] = v;
}
}
if (c.configurationType) SITE.configurationType = c.configurationType;
if (c.domain) SITE.domain = c.domain;
if (c.defaults) SITE.defaults = c.defaults;
if (c.routingMode) SITE.routingMode = c.routingMode;
SITE.onboardingCompleted = c.onboardingCompleted === true;
// Cache only non-sensitive display config (TLD, domain, routing mode)
// DNS IPs and server topology are NOT cached — fetched from API each load
localStorage.setItem('dashcaddy_site_config', JSON.stringify({
tld: SITE.tld, configurationType: SITE.configurationType,
domain: SITE.domain, routingMode: SITE.routingMode
}));
// Render DNS cards dynamically based on configured servers
renderDnsCards();
// Hide Tokens button if no DNS servers configured
const tokensBtn = document.getElementById('manage-tokens');
if (tokensBtn) tokensBtn.style.display = Object.keys(SITE.dnsServers).length ? '' : 'none';
}
} 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 buildServiceUrl(sub) {
if (SITE.routingMode === 'subdirectory' && SITE.domain) return 'https://' + SITE.domain + '/' + sub;
if (SITE.configurationType === 'public' && SITE.domain) return 'https://' + sub + '.' + SITE.domain;
return 'https://' + buildDomain(sub);
}
function getDnsServerAddr(dnsId) {
const s = SITE.dnsServers[dnsId];
return s ? `${s.ip}:${s.port}` : buildDomain(dnsId);
}
/** Get the DNS ID whose IP matches the primary DNS config (dns.ip) */
function getPrimaryDnsId() {
if (!SITE.dnsIp) return null;
for (const [id, info] of Object.entries(SITE.dnsServers)) {
if (info.ip === SITE.dnsIp) return id;
}
return null;
}
// ===== 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 = '';
const firstChild = topRow.firstElementChild;
dnsIds.forEach(id => {
const safeId = escapeHtml(id);
const label = escapeHtml((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 =
``
+ `
${svgIcon}
`
+ `
${label}`
+ `
OFF `
+ `--
`
+ ``
+ ``
+ ``
+ ``
+ ``
+ ``
+ `
`;
topRow.insertBefore(card, firstChild);
});
}
window.renderDnsCards = renderDnsCards;
// ===== CSRF PROTECTION =====
let csrfToken = null;
/**
* Get CSRF token from server (cached)
* @returns {Promise} 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} Fetch response
*/
async function secureFetch(url, options = {}) {
const method = (options.method || 'GET').toUpperCase();
// Add CSRF token for state-changing methods
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
try {
const token = await getCSRFToken();
options.headers = {
...options.headers,
'X-CSRF-Token': token
};
} catch (error) {
console.error('Failed to add CSRF token to request:', error);
}
}
// Default 15s timeout if no signal provided (prevents hanging requests)
if (!options.signal) {
options = { ...options, signal: AbortSignal.timeout(15000) };
}
return fetch(url, options);
}
// ===== API CALL HELPERS =====
/** POST JSON and return parsed response. Throws on HTTP or API error. */
async function postJSON(url, data) {
const resp = await secureFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok || result.success === false) {
throw new Error(result.error || `Request failed (${resp.status})`);
}
return result;
}
/** GET JSON and return parsed response. Throws on HTTP error with server message. */
async function getJSON(url) {
const resp = await secureFetch(url);
if (!resp.ok) {
let msg = `Request failed (${resp.status})`;
try { const body = await resp.json(); msg = body.error || msg; } catch (_) {}
throw new Error(msg);
}
return resp.json();
}
/** DELETE request. Returns parsed JSON response. */
async function deleteAPI(url) {
const resp = await secureFetch(url, { method: 'DELETE' });
const result = await resp.json();
if (!resp.ok || result.success === false) {
throw new Error(result.error || `Delete failed (${resp.status})`);
}
return result;
}
/**
* Run an async operation with button loading state.
* Disables the button, shows loading text, restores on complete.
* @param {HTMLElement} btn - Button element
* @param {string} loadingText - Text to show while loading (e.g. 'Saving...')
* @param {function} asyncFn - Async function to execute
* @param {Object} opts - Options: { successText, resetDelay }
*/
async function withButton(btn, loadingText, asyncFn, opts = {}) {
const original = btn.innerHTML;
const { successText = '✅', resetDelay = DC.DELAYS.BTN_RESET } = opts;
btn.disabled = true;
btn.innerHTML = loadingText;
try {
const result = await asyncFn();
btn.innerHTML = successText;
setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, resetDelay);
return result;
} catch (e) {
btn.innerHTML = original;
btn.disabled = false;
throw e;
}
}
/** Show/hide a modal by ID */
function openModal(id) {
document.getElementById(id)?.classList.add('show');
}
function closeModal(id) {
document.getElementById(id)?.classList.remove('show');
}
/** Wire backdrop-click close + optional close buttons for a modal element */
function wireModal(modal, ...closeBtns) {
if (!modal) return;
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('show'); });
closeBtns.forEach(btn => btn?.addEventListener('click', () => modal.classList.remove('show')));
}
/** Toast-style notification (replaces all alert() usage) */
function showNotification(text, type = 'info', duration = 3000) {
const existingNotif = document.querySelector('.deploy-notification');
if (existingNotif) existingNotif.remove();
const colors = {
info: { bg: '#2196F3', fg: '#fff' },
success: { bg: 'var(--ok-bg)', fg: 'var(--ok-fg)' },
error: { bg: '#f44336', fg: '#fff' },
warning: { bg: '#ff9800', fg: '#fff' }
};
const c = colors[type] || colors.info;
const msg = document.createElement('div');
msg.className = 'deploy-notification';
msg.textContent = text;
msg.style.cssText = `
position: fixed; top: 20px; right: 20px;
background: ${c.bg}; color: ${c.fg};
padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px;
`;
document.body.appendChild(msg);
if (duration > 0) setTimeout(() => msg.remove(), duration);
}
/** Relative time display (e.g. "5m ago", "2h ago") */
function timeAgo(ts) {
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
return Math.floor(diff / 86400000) + 'd ago';
}
// ===== SAFE STORAGE WRAPPERS =====
// Prevents crashes in Safari private browsing, quota exceeded, or restricted environments
function safeGet(key, fallback = null) {
try { const v = localStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; }
}
function safeSet(key, value) {
try { localStorage.setItem(key, value); } catch (_) { /* quota exceeded or private mode */ }
}
function safeRemove(key) {
try { localStorage.removeItem(key); } catch (_) {}
}
function safeSessionGet(key, fallback = null) {
try { const v = sessionStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; }
}
function safeSessionSet(key, value) {
try { sessionStorage.setItem(key, value); } catch (_) {}
}
/** Parse JSON from localStorage with fallback — avoids try/catch at every call site */
function safeGetJSON(key, fallback = null) {
try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch (_) { return fallback; }
}
/** Escape HTML entities for safe innerHTML insertion (handles both content and attributes) */
function escapeHtml(text) {
return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
/** 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) {
for (const [k, v] of Object.entries(changes)) {
if (k !== '__proto__' && k !== 'constructor' && k !== 'prototype') app[k] = v;
}
DC_BUS.emit('apps:changed', this._apps);
}
return app;
}
};