Files
dashcaddy/status/js/globals.js
Sami f2f33b4b40 Make DNS servers fully dynamic from config.json
DNS server IDs (dns1, dns2, dns3) were hardcoded throughout the frontend
and backend. Now config.json's dnsServers object is the single source of
truth — adding or removing a DNS server in config automatically updates
the dashboard cards, credential modal, health checks, and probes.

- credentials.js: rebuild modal sections dynamically from SITE.dnsServers
- globals.js: add getPrimaryDnsId() helper for primary DNS lookups
- service-create.js, service-infrastructure.js: use dynamic DNS ID
- startup-validator.js: dynamic topCardServices from config
- middleware.js: add license endpoints to public routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:55:07 -07:00

376 lines
14 KiB
JavaScript

// ===== 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();
}
} 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 = '<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 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 =
`<span id="${safeId}-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="${safeId}-pill" class="badge off">OFF</span></div>`
+ `<div class="response-row"><span id="${safeId}-time" class="response-time">--</span></div>`
+ `<div class="btn-row">`
+ `<button id="${safeId}-restart" class="restart-btn">Restart</button>`
+ `<button id="${safeId}-update" class="update-btn" title="Update DNS server">⬆️</button>`
+ `<button id="${safeId}-open">Open</button>`
+ `<button id="${safeId}-logs" class="logs-btn">Logs</button>`
+ `<button id="${safeId}-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) {
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;
}
};