Apps can now be served at domain.com/appname/ instead of requiring subdomain DNS records (appname.domain.com). Supports three subpath modes per template: native (URL base env var), strip (handle_path), and none (incompatible warning). Tested on Linux with deploy/removal lifecycle verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
357 lines
14 KiB
JavaScript
357 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) =====
|
|
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) || {},
|
|
routingMode: (_cachedCfg && _cachedCfg.routingMode) || 'subdomain'
|
|
};
|
|
(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;
|
|
if (c.routingMode) SITE.routingMode = c.routingMode;
|
|
// 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,
|
|
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);
|
|
}
|
|
|
|
// ===== DYNAMIC DNS CARD RENDERER =====
|
|
function renderDnsCards() {
|
|
const topRow = document.querySelector('.top');
|
|
if (!topRow) return;
|
|
const dnsIds = Object.keys(SITE.dnsServers);
|
|
if (!dnsIds.length) return; // No DNS servers configured — show nothing
|
|
|
|
const svgIcon = '<svg viewBox="0 0 24 24" class="service-icon">'
|
|
+ '<rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/>'
|
|
+ '<rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/>'
|
|
+ '<rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/>'
|
|
+ '<rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/>'
|
|
+ '<rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/>'
|
|
+ '<rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/>'
|
|
+ '<circle cx="17" cy="11" r="2" fill="#e74c3c"/>'
|
|
+ '<path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>';
|
|
|
|
const firstChild = topRow.firstElementChild;
|
|
dnsIds.forEach(id => {
|
|
const label = (SITE.dnsServers[id].name || id).toUpperCase();
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.setAttribute('data-app', id);
|
|
card.setAttribute('data-status', 'off');
|
|
card.innerHTML =
|
|
`<span id="${id}-dot" class="dot bad at-bl"></span>`
|
|
+ `<div class="row"><div class="logo-wrap">${svgIcon}</div>`
|
|
+ `<span class="name">${label}</span><span class="spacer"></span>`
|
|
+ `<span id="${id}-pill" class="badge off">OFF</span></div>`
|
|
+ `<div class="response-row"><span id="${id}-time" class="response-time">--</span></div>`
|
|
+ `<div class="btn-row">`
|
|
+ `<button id="${id}-restart" class="restart-btn">Restart</button>`
|
|
+ `<button id="${id}-update" class="update-btn" title="Update DNS server">⬆️</button>`
|
|
+ `<button id="${id}-open">Open</button>`
|
|
+ `<button id="${id}-logs" class="logs-btn">Logs</button>`
|
|
+ `<button id="${id}-settings" class="settings-btn">⚙️</button>`
|
|
+ `</div>`;
|
|
topRow.insertBefore(card, firstChild);
|
|
});
|
|
}
|
|
window.renderDnsCards = renderDnsCards;
|
|
|
|
// ===== CSRF PROTECTION =====
|
|
let csrfToken = null;
|
|
|
|
/**
|
|
* Get CSRF token from server (cached)
|
|
* @returns {Promise<string>} CSRF token
|
|
*/
|
|
async function getCSRFToken() {
|
|
if (csrfToken) return csrfToken;
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/csrf-token');
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch CSRF token');
|
|
}
|
|
const data = await response.json();
|
|
csrfToken = data.token;
|
|
return csrfToken;
|
|
} catch (error) {
|
|
console.error('Failed to get CSRF token:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Secure fetch wrapper that automatically adds CSRF token to state-changing requests
|
|
* @param {string} url - URL to fetch
|
|
* @param {Object} options - Fetch options
|
|
* @returns {Promise<Response>} Fetch response
|
|
*/
|
|
async function secureFetch(url, options = {}) {
|
|
const method = (options.method || 'GET').toUpperCase();
|
|
|
|
// Add CSRF token for state-changing methods
|
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
|
try {
|
|
const token = await getCSRFToken();
|
|
options.headers = {
|
|
...options.headers,
|
|
'X-CSRF-Token': token
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to add CSRF token to request:', error);
|
|
}
|
|
}
|
|
|
|
// Default 15s timeout if no signal provided (prevents hanging requests)
|
|
if (!options.signal) {
|
|
options = { ...options, signal: AbortSignal.timeout(15000) };
|
|
}
|
|
|
|
return fetch(url, options);
|
|
}
|
|
|
|
// ===== API CALL HELPERS =====
|
|
|
|
/** POST JSON and return parsed response. Throws on HTTP or API error. */
|
|
async function postJSON(url, data) {
|
|
const resp = await secureFetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
const result = await resp.json();
|
|
if (!resp.ok || result.success === false) {
|
|
throw new Error(result.error || `Request failed (${resp.status})`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** GET JSON and return parsed response. Throws on HTTP error with server message. */
|
|
async function getJSON(url) {
|
|
const resp = await secureFetch(url);
|
|
if (!resp.ok) {
|
|
let msg = `Request failed (${resp.status})`;
|
|
try { const body = await resp.json(); msg = body.error || msg; } catch (_) {}
|
|
throw new Error(msg);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
/** DELETE request. Returns parsed JSON response. */
|
|
async function deleteAPI(url) {
|
|
const resp = await secureFetch(url, { method: 'DELETE' });
|
|
const result = await resp.json();
|
|
if (!resp.ok || result.success === false) {
|
|
throw new Error(result.error || `Delete failed (${resp.status})`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Run an async operation with button loading state.
|
|
* Disables the button, shows loading text, restores on complete.
|
|
* @param {HTMLElement} btn - Button element
|
|
* @param {string} loadingText - Text to show while loading (e.g. 'Saving...')
|
|
* @param {function} asyncFn - Async function to execute
|
|
* @param {Object} opts - Options: { successText, resetDelay }
|
|
*/
|
|
async function withButton(btn, loadingText, asyncFn, opts = {}) {
|
|
const original = btn.innerHTML;
|
|
const { successText = '✅', resetDelay = DC.DELAYS.BTN_RESET } = opts;
|
|
btn.disabled = true;
|
|
btn.innerHTML = loadingText;
|
|
try {
|
|
const result = await asyncFn();
|
|
btn.innerHTML = successText;
|
|
setTimeout(() => { btn.innerHTML = original; btn.disabled = false; }, resetDelay);
|
|
return result;
|
|
} catch (e) {
|
|
btn.innerHTML = original;
|
|
btn.disabled = false;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** Show/hide a modal by ID */
|
|
function openModal(id) {
|
|
document.getElementById(id)?.classList.add('show');
|
|
}
|
|
function closeModal(id) {
|
|
document.getElementById(id)?.classList.remove('show');
|
|
}
|
|
|
|
/** Wire backdrop-click close + optional close buttons for a modal element */
|
|
function wireModal(modal, ...closeBtns) {
|
|
if (!modal) return;
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('show'); });
|
|
closeBtns.forEach(btn => btn?.addEventListener('click', () => modal.classList.remove('show')));
|
|
}
|
|
|
|
/** Toast-style notification (replaces all alert() usage) */
|
|
function showNotification(text, type = 'info', duration = 3000) {
|
|
const existingNotif = document.querySelector('.deploy-notification');
|
|
if (existingNotif) existingNotif.remove();
|
|
|
|
const colors = {
|
|
info: { bg: '#2196F3', fg: '#fff' },
|
|
success: { bg: 'var(--ok-bg)', fg: 'var(--ok-fg)' },
|
|
error: { bg: '#f44336', fg: '#fff' },
|
|
warning: { bg: '#ff9800', fg: '#fff' }
|
|
};
|
|
|
|
const c = colors[type] || colors.info;
|
|
const msg = document.createElement('div');
|
|
msg.className = 'deploy-notification';
|
|
msg.textContent = text;
|
|
msg.style.cssText = `
|
|
position: fixed; top: 20px; right: 20px;
|
|
background: ${c.bg}; color: ${c.fg};
|
|
padding: 16px 24px; border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
|
z-index: 10000; animation: slideIn 0.3s ease-out;
|
|
max-width: 400px; white-space: pre-line; font-size: 14px;
|
|
`;
|
|
document.body.appendChild(msg);
|
|
if (duration > 0) setTimeout(() => msg.remove(), duration);
|
|
}
|
|
|
|
/** Relative time display (e.g. "5m ago", "2h ago") */
|
|
function timeAgo(ts) {
|
|
const diff = Date.now() - new Date(ts).getTime();
|
|
if (diff < 60000) return 'just now';
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
|
return Math.floor(diff / 86400000) + 'd ago';
|
|
}
|
|
|
|
// ===== SAFE STORAGE WRAPPERS =====
|
|
// Prevents crashes in Safari private browsing, quota exceeded, or restricted environments
|
|
function safeGet(key, fallback = null) {
|
|
try { const v = localStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; }
|
|
}
|
|
function safeSet(key, value) {
|
|
try { localStorage.setItem(key, value); } catch (_) { /* quota exceeded or private mode */ }
|
|
}
|
|
function safeRemove(key) {
|
|
try { localStorage.removeItem(key); } catch (_) {}
|
|
}
|
|
function safeSessionGet(key, fallback = null) {
|
|
try { const v = sessionStorage.getItem(key); return v !== null ? v : fallback; } catch (_) { return fallback; }
|
|
}
|
|
function safeSessionSet(key, value) {
|
|
try { sessionStorage.setItem(key, value); } catch (_) {}
|
|
}
|
|
/** Parse JSON from localStorage with fallback — avoids try/catch at every call site */
|
|
function safeGetJSON(key, fallback = null) {
|
|
try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch (_) { return fallback; }
|
|
}
|
|
|
|
/** Escape HTML entities for safe innerHTML insertion (handles both content and attributes) */
|
|
function escapeHtml(text) {
|
|
return String(text ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
/** Inject modal HTML into DOM (idempotent — skips if element already exists) */
|
|
function injectModal(id, html) {
|
|
if (document.getElementById(id)) return;
|
|
document.body.insertAdjacentHTML('beforeend', html);
|
|
}
|
|
|
|
// ===== EVENT BUS =====
|
|
// Lightweight pub/sub for cross-module communication without window globals.
|
|
// Usage: DC_BUS.on('services:loaded', handler); DC_BUS.emit('services:loaded', data);
|
|
const DC_BUS = {
|
|
_handlers: {},
|
|
on(event, fn) { (this._handlers[event] ||= []).push(fn); },
|
|
off(event, fn) { this._handlers[event] = this._handlers[event]?.filter(h => h !== fn); },
|
|
emit(event, data) { this._handlers[event]?.forEach(fn => fn(data)); }
|
|
};
|
|
|
|
// ===== CENTRALIZED APP STATE =====
|
|
// Single source of truth for the services array. Modules should use AppState
|
|
// instead of mutating window.APPS directly. Emits 'apps:changed' on updates.
|
|
const AppState = {
|
|
_apps: [],
|
|
getApps() { return this._apps; },
|
|
setApps(apps) { this._apps = apps; window.APPS = apps; DC_BUS.emit('apps:changed', apps); },
|
|
findApp(id) { return this._apps.find(a => a.id === id); },
|
|
addApp(app) { this._apps.push(app); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); },
|
|
removeApp(id) {
|
|
const idx = this._apps.findIndex(a => a.id === id);
|
|
if (idx > -1) { this._apps.splice(idx, 1); window.APPS = this._apps; DC_BUS.emit('apps:changed', this._apps); }
|
|
return idx > -1;
|
|
},
|
|
updateApp(id, changes) {
|
|
const app = this._apps.find(a => a.id === id);
|
|
if (app) { Object.assign(app, changes); DC_BUS.emit('apps:changed', this._apps); }
|
|
return app;
|
|
}
|
|
};
|