Initial commit: DashCaddy v1.0

Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
2026-03-05 02:26:12 -08:00
commit f61e85d9a7
337 changed files with 75282 additions and 0 deletions

View File

@@ -0,0 +1,387 @@
// ========== CREDENTIAL MANAGEMENT ==========
(function () {
// Inject the token-management modal HTML
injectModal('token-management-modal', `
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>🔑 DNS Credentials</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates.
</p>
<!-- DNS1 Credentials -->
<div class="token-section">
<h4 class="token-section-title">DNS1 (Windows)</h4>
<div class="token-grid">
<div class="token-field">
<label for="dns1-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="dns1-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns1-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns1-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="dns1-admin-username">\u{1F527} Admin:</label>
<input type="text" id="dns1-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns1-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns1-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="dns1-token-status"></div>
</div>
<!-- DNS2 Credentials -->
<div class="token-section">
<h4 class="token-section-title">DNS2 (Linux)</h4>
<div class="token-grid">
<div class="token-field">
<label for="dns2-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="dns2-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns2-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns2-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="dns2-admin-username">\u{1F527} Admin:</label>
<input type="text" id="dns2-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns2-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns2-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="dns2-token-status"></div>
</div>
<!-- DNS3 Credentials -->
<div class="token-section">
<h4 class="token-section-title">DNS3 (AlmaLinux)</h4>
<div class="token-grid">
<div class="token-field">
<label for="dns3-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="dns3-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns3-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns3-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="dns3-admin-username">\u{1F527} Admin:</label>
<input type="text" id="dns3-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="dns3-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="dns3-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="dns3-token-status"></div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
<button id="token-cancel">Cancel</button>
<button id="token-save" class="btn-accent">Save</button>
</div>
</div>
</div>
`);
// Simple encryption for storing credentials - key is generated per installation
function getEncryptionKey() {
let key = safeGet('dashcaddy-encryption-key');
if (!key) {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
key = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
safeSet('dashcaddy-encryption-key', key);
}
return key;
}
const ENCRYPTION_KEY = getEncryptionKey();
function simpleEncrypt(text, key) {
if (!text) return '';
let result = '';
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length);
result += String.fromCharCode(charCode);
}
return btoa(result);
}
function simpleDecrypt(encryptedText, key) {
if (!encryptedText) return '';
try {
const decoded = atob(encryptedText);
let result = '';
for (let i = 0; i < decoded.length; i++) {
const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length);
result += String.fromCharCode(charCode);
}
return result;
} catch (e) {
return '';
}
}
// Credential storage functions
function getCredential(dnsId, tokenType, credType) {
const encrypted = safeGet(`${dnsId}-${tokenType}-${credType}-enc`);
return simpleDecrypt(encrypted, ENCRYPTION_KEY);
}
function setCredential(dnsId, tokenType, credType, value) {
const key = `${dnsId}-${tokenType}-${credType}-enc`;
if (value) {
safeSet(key, simpleEncrypt(value, ENCRYPTION_KEY));
} else {
safeRemove(key);
}
}
function getToken(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'token');
}
function getUsername(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'username');
}
function setToken(dnsId, tokenType, token) {
setCredential(dnsId, tokenType, 'token', token);
}
function setUsername(dnsId, tokenType, username) {
setCredential(dnsId, tokenType, 'username', username);
}
function getAllCredentials() {
return {
dns1: {
readonly: { username: getUsername('dns1', 'readonly'), token: getToken('dns1', 'readonly') },
admin: { username: getUsername('dns1', 'admin'), token: getToken('dns1', 'admin') }
},
dns2: {
readonly: { username: getUsername('dns2', 'readonly'), token: getToken('dns2', 'readonly') },
admin: { username: getUsername('dns2', 'admin'), token: getToken('dns2', 'admin') }
},
dns3: {
readonly: { username: getUsername('dns3', 'readonly'), token: getToken('dns3', 'readonly') },
admin: { username: getUsername('dns3', 'admin'), token: getToken('dns3', 'admin') }
}
};
}
function clearAllCredentials() {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
['readonly', 'admin'].forEach(tokenType => {
['token', 'username'].forEach(credType => {
safeRemove(`${dnsId}-${tokenType}-${credType}-enc`);
});
});
safeRemove(`${dnsId}-token-enc`);
safeRemove(`${dnsId}-username-enc`);
});
}
function getStoredCredentials(dnsId) {
const readonlyToken = getToken(dnsId, 'readonly');
const readonlyUsername = getUsername(dnsId, 'readonly');
const adminToken = getToken(dnsId, 'admin');
const adminUsername = getUsername(dnsId, 'admin');
const oldToken = simpleDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY);
const oldUsername = simpleDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY);
return {
username: adminUsername || readonlyUsername || oldUsername,
token: adminToken || readonlyToken || oldToken,
readonlyToken: readonlyToken || oldToken,
readonlyUsername: readonlyUsername || oldUsername,
adminToken: adminToken || oldToken,
adminUsername: adminUsername || oldUsername
};
}
// Token Management Modal handlers
document.getElementById('manage-tokens')?.addEventListener('click', () => {
const modal = document.getElementById('token-management-modal');
const creds = getAllCredentials();
// Populate fields with existing credentials
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = creds[dnsId].readonly.username;
document.getElementById(`${dnsId}-readonly-token`).value = creds[dnsId].readonly.token;
document.getElementById(`${dnsId}-admin-username`).value = creds[dnsId].admin.username;
document.getElementById(`${dnsId}-admin-token`).value = creds[dnsId].admin.token;
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
modal.classList.add('show');
});
// Toggle password visibility
document.querySelectorAll('.token-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.target;
const input = document.getElementById(targetId);
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '\u{1F648}';
} else {
input.type = 'password';
btn.textContent = '\u{1F441}';
}
});
});
document.getElementById('token-save')?.addEventListener('click', async () => {
// Save all credentials to localStorage
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
// Build per-server credentials payload for backend sync
const servers = {};
let hasAnyCreds = false;
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
const entry = {};
const roUser = document.getElementById(`${dnsId}-readonly-username`).value.trim();
const roPass = document.getElementById(`${dnsId}-readonly-token`).value.trim();
const adminUser = document.getElementById(`${dnsId}-admin-username`).value.trim();
const adminPass = document.getElementById(`${dnsId}-admin-token`).value.trim();
if (roUser && roPass) {
entry.readonly = { username: roUser, password: roPass };
hasAnyCreds = true;
}
if (adminUser && adminPass) {
entry.admin = { username: adminUser, password: adminPass };
hasAnyCreds = true;
}
if (Object.keys(entry).length > 0) {
servers[dnsId] = entry;
}
});
if (hasAnyCreds) {
// Show syncing status
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = 'Verifying...';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
try {
const res = await secureFetch('/api/v1/dns/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const data = await res.json();
if (data.results) {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results[dnsId];
if (result?.success) {
statusEl.textContent = '\u2713 Verified & saved';
statusEl.className = 'token-status success';
} else if (result?.partial) {
statusEl.textContent = '\u2713 ' + result.partial;
statusEl.className = 'token-status success';
} else {
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
statusEl.className = 'token-status error';
}
});
} else if (data.success) {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
}
});
} else {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
}
} catch (e) {
console.error('Failed to sync DNS credentials to backend:', e);
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
}
} else {
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
}
// Auto-close after delay if all succeeded
setTimeout(() => {
const allGood = ['dns1', 'dns2', 'dns3'].every(dnsId => {
const status = document.getElementById(`${dnsId}-token-status`).textContent;
return !status || status.includes('\u2713');
});
if (allGood) closeModal('token-management-modal');
}, 1500);
});
document.getElementById('token-cancel')?.addEventListener('click', () => {
closeModal('token-management-modal');
});
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
clearAllCredentials();
['dns1', 'dns2', 'dns3'].forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
try {
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
} catch (_) {}
}
});
// Close modal on backdrop click
document.getElementById('token-management-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'token-management-modal') {
e.target.classList.remove('show');
}
});
// Window exports
window.getToken = getToken;
window.getUsername = getUsername;
window.setToken = setToken;
window.setUsername = setUsername;
window.getAllCredentials = getAllCredentials;
window.getCredential = getCredential;
window.setCredential = setCredential;
window.getEncryptionKey = getEncryptionKey;
})();

273
status/js/core/dns.js Normal file
View File

@@ -0,0 +1,273 @@
// ========== DNS MANAGEMENT ==========
(function () {
// Restart DNS service via backend proxy (backend handles auth automatically)
async function restartDnsService(dnsId) {
const response = await secureFetch(`/api/v1/dns/restart/${dnsId}`, { method: 'POST' });
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Restart failed');
}
return result;
}
// Event delegation for DNS restart buttons (dynamic cards)
document.querySelector('.top')?.addEventListener('click', async (e) => {
const restartBtn = e.target.closest('[id$="-restart"]');
if (!restartBtn) return;
const dnsId = restartBtn.id.replace('-restart', '');
if (!SITE.dnsServers[dnsId]) return;
if (!confirm(`Restart ${dnsId.toUpperCase()} service?`)) return;
try {
await withButton(restartBtn, '...', () => restartDnsService(dnsId));
setTimeout(window.refreshAll, DC.DELAYS.RELOAD);
} catch (e) {
showNotification('Restart failed: ' + e.message, 'error');
}
});
// DNS Update buttons
async function updateDnsServer(dnsId, serverIp) {
const btn = document.getElementById(`${dnsId}-update`);
const originalText = btn?.textContent || '⬆️';
try {
// First check for updates
btn.textContent = '🔍';
btn.disabled = true;
btn.title = 'Checking for updates...';
const checkResponse = await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(serverIp)}`);
const checkResult = await checkResponse.json();
if (!checkResult.success) {
throw new Error(checkResult.error || 'Failed to check for updates');
}
if (!checkResult.updateAvailable) {
btn.textContent = '✅';
btn.title = `Already on latest version (${checkResult.currentVersion})`;
showNotification(`${dnsId.toUpperCase()} is already up to date! Current version: ${checkResult.currentVersion}`, 'info');
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
btn.title = 'Update DNS server';
}, 3000);
return;
}
// Update available - confirm with user
const confirmUpdate = confirm(
`Update available for ${dnsId.toUpperCase()}!\n\n` +
`Current: ${checkResult.currentVersion}\n` +
`New: ${checkResult.updateVersion}\n\n` +
(checkResult.updateTitle ? `${checkResult.updateTitle}\n\n` : '') +
`The DNS server will restart during the update.\nProceed?`
);
if (!confirmUpdate) {
btn.textContent = originalText;
btn.disabled = false;
btn.title = 'Update DNS server';
return;
}
// Perform the update
btn.textContent = '🔄';
btn.title = 'Updating...';
const updateResponse = await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(serverIp)}`, {
method: 'POST'
});
const updateResult = await updateResponse.json();
if (!updateResult.success) {
throw new Error(updateResult.error || 'Update failed');
}
if (updateResult.manualUpdateRequired) {
// Technitium v14+ doesn't support auto-install via API
btn.textContent = '⬆️';
btn.title = `Update available: ${updateResult.newVersion}`;
const downloadInfo = updateResult.downloadLink
? `\nDownload: ${updateResult.downloadLink}`
: '';
const instructionsInfo = updateResult.instructionsLink
? `\nInstructions: ${updateResult.instructionsLink}`
: '';
showNotification(`${dnsId.toUpperCase()} update requires manual installation. Current: ${updateResult.previousVersion}${updateResult.newVersion}. Please update manually on the host machine.`, 'warning', 8000);
btn.disabled = false;
return;
}
btn.textContent = '✅';
btn.title = 'Updated successfully!';
showNotification(`${dnsId.toUpperCase()} updated successfully! ${updateResult.previousVersion}${updateResult.newVersion}. Server is restarting...`, 'success');
// Refresh after delay for server restart
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
btn.title = 'Update DNS server';
window.refreshAll();
}, 10000);
} catch (error) {
console.error('DNS update error:', error);
btn.textContent = '❌';
btn.title = 'Update failed';
showNotification(`Failed to update ${dnsId.toUpperCase()}: ${error.message}`, 'error');
setTimeout(() => {
btn.textContent = originalText;
btn.disabled = false;
btn.title = 'Update DNS server';
}, 3000);
}
}
// Event delegation for DNS update buttons (dynamic cards)
document.querySelector('.top')?.addEventListener('click', (e) => {
const updateBtn = e.target.closest('[id$="-update"]');
if (!updateBtn) return;
const dnsId = updateBtn.id.replace('-update', '');
if (!SITE.dnsServers[dnsId]) return;
updateDnsServer(dnsId, SITE.dnsServers[dnsId]?.ip);
});
// ===== DNS SETTINGS MODAL =====
// Inject modal HTML
injectModal('dns-settings-modal', `
<div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3>
<div style="display: grid; gap: 16px; margin-top: 16px;">
<div>
<label for="dns-edit-ip" class="form-label-accent-sm">Server IP</label>
<input type="text" id="dns-edit-ip" class="form-input-md" placeholder="192.168.1.1" />
</div>
<div>
<label for="dns-edit-port" class="form-label-accent-sm">Port</label>
<input type="number" id="dns-edit-port" class="form-input-md" placeholder="5380" />
</div>
<div>
<label for="dns-edit-name" class="form-label-accent-sm">Display Name (optional)</label>
<input type="text" id="dns-edit-name" class="form-input-md" placeholder="e.g. Primary DNS" />
</div>
<div class="form-hint-sm">Manage credentials via Tokens in the toolbar</div>
</div>
<div class="weather-modal-buttons" style="margin-top: 24px;">
<button id="dns-settings-cancel">Cancel</button>
<button id="dns-settings-delete" style="background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">Remove</button>
<button id="dns-settings-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);
let currentDnsId = null;
function openDnsSettings(dnsId) {
currentDnsId = dnsId;
const server = SITE.dnsServers[dnsId] || {};
const modal = document.getElementById('dns-settings-modal');
document.getElementById('dns-settings-title').textContent = `${(server.name || dnsId).toUpperCase()} Settings`;
document.getElementById('dns-edit-ip').value = server.ip || '';
document.getElementById('dns-edit-port').value = server.port || DC.DEFAULTS.DNS_PORT;
document.getElementById('dns-edit-name').value = server.name || '';
modal.classList.add('show');
}
async function saveDnsSettings() {
if (!currentDnsId) return;
const ip = document.getElementById('dns-edit-ip').value.trim();
const port = document.getElementById('dns-edit-port').value.trim() || DC.DEFAULTS.DNS_PORT;
const name = document.getElementById('dns-edit-name').value.trim();
if (!ip) {
showNotification('Server IP is required', 'warning');
return;
}
const update = { dnsServers: {} };
update.dnsServers[currentDnsId] = { ip, port: String(port) };
if (name) update.dnsServers[currentDnsId].name = name;
try {
const response = await secureFetch('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update)
});
const result = await response.json();
if (result.success) {
SITE.dnsServers[currentDnsId] = update.dnsServers[currentDnsId];
showNotification(`${currentDnsId.toUpperCase()} settings saved`, 'success');
closeDnsSettings();
window.refreshAll();
} else {
showNotification(result.error || 'Failed to save settings', 'error');
}
} catch (err) {
showNotification('Failed to save: ' + err.message, 'error');
}
}
async function removeDnsServer() {
if (!currentDnsId) return;
if (!confirm(`Remove ${currentDnsId.toUpperCase()} from dashboard? This won't affect the actual DNS server.`)) return;
// Remove by setting to null in dnsServers
try {
const resp = await secureFetch('/api/v1/config');
const config = await resp.json();
if (config.dnsServers) {
delete config.dnsServers[currentDnsId];
}
const saveResp = await secureFetch('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dnsServers: config.dnsServers || {} })
});
const result = await saveResp.json();
if (result.success) {
delete SITE.dnsServers[currentDnsId];
// Remove card from DOM
const card = document.querySelector(`.top [data-app="${currentDnsId}"]`);
if (card) card.remove();
showNotification(`${currentDnsId.toUpperCase()} removed from dashboard`, 'success');
closeDnsSettings();
} else {
showNotification(result.error || 'Failed to remove', 'error');
}
} catch (err) {
showNotification('Failed to remove: ' + err.message, 'error');
}
}
function closeDnsSettings() {
closeModal('dns-settings-modal');
currentDnsId = null;
}
// Event listeners for modal buttons
document.getElementById('dns-settings-cancel')?.addEventListener('click', closeDnsSettings);
document.getElementById('dns-settings-save')?.addEventListener('click', saveDnsSettings);
document.getElementById('dns-settings-delete')?.addEventListener('click', removeDnsServer);
document.getElementById('dns-settings-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'dns-settings-modal') closeDnsSettings();
});
// Event delegation for DNS settings buttons (dynamic cards)
document.querySelector('.top')?.addEventListener('click', (e) => {
const settingsBtn = e.target.closest('[id$="-settings"]');
if (!settingsBtn) return;
const dnsId = settingsBtn.id.replace('-settings', '');
if (!SITE.dnsServers[dnsId]) return;
e.stopPropagation();
openDnsSettings(dnsId);
});
document.getElementById('refresh')?.addEventListener('click', window.refreshAll);
})();

384
status/js/core/grid.js Normal file
View File

@@ -0,0 +1,384 @@
// ========== GRID & STATUS HELPERS ==========
(function () {
/* Enhanced status helpers with response time tracking */
function setQuick(id, up, responseTime = null) {
const dot = document.getElementById(id + '-dot');
const pill = document.getElementById(id + '-pill');
const timeEl = document.getElementById(id + '-time');
const card = document.querySelector(`[data-app="${id}"]`);
if (dot) {
dot.classList.toggle('ok', up);
dot.classList.toggle('bad', !up);
}
if (pill) {
pill.textContent = up ? 'ON' : 'OFF';
pill.classList.toggle('on', up);
pill.classList.toggle('off', !up);
}
if (timeEl && responseTime !== null) {
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
}
// Update card status for icon coloring
if (card) {
card.setAttribute('data-status', up ? 'on' : 'off');
}
// Internet card packet blink effect
if (id === 'internet' && dot && up) {
blinkInternetPacket(dot);
}
}
// Internet card packet activity blink
let internetBlinkInterval = null;
function blinkInternetPacket(dot) {
// Alternate between rx (green) and tx (blue) to simulate bidirectional traffic
const isRx = Math.random() > 0.5;
dot.classList.add(isRx ? 'packet-rx' : 'packet-tx');
setTimeout(() => {
dot.classList.remove('packet-rx', 'packet-tx');
}, 150);
}
// Continuous packet simulation for Internet card when online
function startInternetPacketSimulation() {
if (internetBlinkInterval) return;
internetBlinkInterval = setInterval(() => {
const dot = document.getElementById('internet-dot');
const card = document.querySelector('[data-app="internet"]');
if (dot && card && card.getAttribute('data-status') === 'on') {
blinkInternetPacket(dot);
}
}, 300 + Math.random() * 400); // Random interval 300-700ms
}
function stopInternetPacketSimulation() {
if (internetBlinkInterval) {
clearInterval(internetBlinkInterval);
internetBlinkInterval = null;
}
}
// Start simulation on page load
startInternetPacketSimulation();
function getResponseTimeClass(time, isUp) {
if (!isUp) return 'timeout';
if (time < 200) return 'excellent';
if (time < 500) return 'good';
if (time < 1000) return 'fair';
return 'slow';
}
async function checkServiceWithTiming(id) {
const startTime = performance.now();
try {
const r = await fetch('/probe/' + id, { cache: 'no-store' });
const endTime = performance.now();
const responseTime = Math.round(endTime - startTime);
const isUp = (r.status >= 200 && r.status < 400) || r.status === 401 || r.status === 403;
return { isUp, responseTime };
} catch {
const endTime = performance.now();
const responseTime = Math.round(endTime - startTime);
return { isUp: false, responseTime };
}
}
/* App grid - loaded from API */
window.APPS = []; // Use window.APPS as the main array
// Load services from API
async function loadServices() {
try {
if (window.SkeletonLoader) window.SkeletonLoader.show(6);
const response = await fetch('/api/v1/services', { cache: 'no-store' });
if (response.ok) {
window.APPS = await response.json();
if (window.SkeletonLoader) window.SkeletonLoader.hide();
} else {
console.error('Failed to load services:', response.status);
if (window.SkeletonLoader) window.SkeletonLoader.hide();
}
} catch (error) {
console.error('Failed to load services:', error);
if (window.SkeletonLoader) window.SkeletonLoader.hide();
}
}
function serviceUrl(id) { return `https://${buildDomain(id)}`; }
function el(tag, cls, txt) { const n = document.createElement(tag); if (cls) n.className = cls; if (txt) n.textContent = txt; return n; }
function buildGrid() {
const root = document.getElementById('cards'); root.innerHTML = '';
for (let i = 0; i < window.APPS.length; i++) {
const s = window.APPS[i];
if (s.id === 'ca') continue; // DashCA lives in top anchor row
const card = el('div', 'card');
card.setAttribute('data-app', s.id);
card.setAttribute('data-status', 'off'); // Initial status
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
const row = el('div', 'row');
const wrap = el('div', 'logo-wrap');
// Use reliable PNG images with automatic CDN fallback
const img = document.createElement('img');
img.src = s.logo;
img.alt = s.name;
img.className = 'logo-img';
img.onerror = function() {
// Try CDN fallback with multiple naming strategies
// Use id, appTemplate, or derive from name
let appId = s.id || s.appTemplate;
if (!appId && s.name) {
// Derive ID from name (lowercase, remove spaces)
appId = s.name.toLowerCase().replace(/\s+/g, '-');
}
if (appId) {
// Try different CDN URL formats
const cdnUrls = [
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId}.png`,
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.toLowerCase()}.png`,
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${appId.replace(/-/g, '')}.png`,
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${s.name.toLowerCase().replace(/\s+/g, '-')}.png`
];
// Remove duplicates
const uniqueUrls = [...new Set(cdnUrls)];
// Find the next URL to try
const currentIndex = uniqueUrls.indexOf(this.src);
const nextIndex = currentIndex + 1;
if (nextIndex < uniqueUrls.length) {
this.src = uniqueUrls[nextIndex];
} else {
this.style.display = 'none';
}
} else {
this.style.display = 'none';
}
};
wrap.appendChild(img);
row.appendChild(wrap);
const nameSpan = el('span', 'name', s.name);
row.appendChild(nameSpan);
// Add Tailscale badge if service is protected
if (s.tailscaleOnly) {
const tsBadge = el('span', 'ts-badge', '🔐');
tsBadge.title = 'Tailscale-only access';
tsBadge.style.cssText = 'margin-left: 6px; font-size: 0.75rem; opacity: 0.8;';
nameSpan.appendChild(tsBadge);
}
row.appendChild(el('span', 'spacer'));
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
// Update available badge (hidden by default, shown when update detected)
const updateBadge = el('span', 'update-available-badge', 'UPDATE');
updateBadge.id = 'update-badge-' + s.id;
updateBadge.title = 'Update available';
row.appendChild(updateBadge);
card.appendChild(row);
// Add response time row
const responseRow = el('div', 'response-row');
const timeSpan = el('span', 'response-time', '--'); timeSpan.id = 'time-' + s.id;
responseRow.appendChild(timeSpan);
card.appendChild(responseRow);
// Add health/uptime row
const healthRow = el('div', 'health-row');
healthRow.id = 'health-' + s.id;
const uptimeChip = el('span', 'uptime-chip', '--');
uptimeChip.id = 'uptime-' + s.id;
healthRow.appendChild(uptimeChip);
const uptimeMiniBar = document.createElement('div');
uptimeMiniBar.className = 'uptime-mini-bar';
const uptimeFill = document.createElement('div');
uptimeFill.className = 'fill';
uptimeFill.id = 'uptime-bar-' + s.id;
uptimeFill.style.width = '0%';
uptimeMiniBar.appendChild(uptimeFill);
healthRow.appendChild(uptimeMiniBar);
card.appendChild(healthRow);
const btnRow = el('div', 'btn-row');
// Add logs button for services with containerIds
if (s.containerId) {
const logsBtn = el('button', 'logs-btn', '📋');
logsBtn.title = 'View container logs';
logsBtn.onclick = (e) => {
e.stopPropagation();
window.openContainerLogsModal(s.containerId, s.name);
};
btnRow.appendChild(logsBtn);
// Add update button for Docker containers
const updateBtn = el('button', 'update-btn', '⬆️');
updateBtn.title = 'Update container to latest version';
updateBtn.id = `update-btn-${s.id}`;
updateBtn.onclick = (e) => {
e.stopPropagation();
window.updateContainer(s.containerId, s.name, s.id);
};
btnRow.appendChild(updateBtn);
}
// Add logs button for services with logPath (native apps)
if (s.logPath && !s.containerId) {
const logsBtn = el('button', 'logs-btn', '📋');
logsBtn.title = 'View application logs';
logsBtn.onclick = (e) => {
e.stopPropagation();
window.openFileLogsModal(s.logPath, s.name);
};
btnRow.appendChild(logsBtn);
}
// Add credentials button for services that support auto-login
if (s.isExternal || s.appTemplate || s.url) {
const credsBtn = el('button', 'creds-btn', '🔑');
credsBtn.title = 'Auto-login credentials';
credsBtn.id = `creds-btn-${s.id}`;
credsBtn.onclick = (e) => {
e.stopPropagation();
window.openServiceCredsModal(s);
};
btnRow.appendChild(credsBtn);
}
// Add options button for all services except 'internet'
if (s.id !== 'internet') {
const optBtn = el('button', 'options-btn', '⚙️');
optBtn.title = 'Edit service settings';
optBtn.onclick = (e) => {
e.stopPropagation();
window.openServiceEditModal(s);
};
btnRow.appendChild(optBtn);
}
// Add delete button for all services except Internet
if (s.id !== 'internet') {
const delBtn = el('button', 'delete-btn', '🗑️');
delBtn.title = 'Delete this service';
delBtn.onclick = (e) => {
e.stopPropagation();
window.deleteService(s.id, s.name);
};
btnRow.appendChild(delBtn);
}
const btn = el('button', null, 'Open');
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
btnRow.appendChild(btn);
card.appendChild(btnRow);
root.appendChild(card);
// Staggered loading animation
setTimeout(() => {
card.classList.add('loaded');
}, i * 100); // 100ms delay between each card
}
// Group recipe cards visually after grid is built
if (window.groupRecipeCards) setTimeout(window.groupRecipeCards, 50);
}
function setBadge(id, up, responseTime = null) {
const dot = document.getElementById('dot-' + id + '-grid');
const pill = document.getElementById('badge-' + id);
const timeEl = document.getElementById('time-' + id);
const card = document.querySelector(`[data-app="${id}"]`);
if (dot) {
dot.classList.toggle('ok', up);
dot.classList.toggle('bad', !up);
}
if (pill) {
pill.textContent = up ? 'ON' : 'OFF';
pill.classList.toggle('on', up);
pill.classList.toggle('off', !up);
}
if (timeEl && responseTime !== null) {
timeEl.textContent = up ? `${responseTime}ms` : 'timeout';
timeEl.className = `response-time ${getResponseTimeClass(responseTime, up)}`;
}
// Update card status for icon coloring
if (card) {
card.setAttribute('data-status', up ? 'on' : 'off');
}
}
async function refreshAll() {
// Check DNS servers dynamically (only those configured in SITE.dnsServers)
const dnsIds = Object.keys(SITE.dnsServers);
const topChecks = dnsIds.map(id => checkServiceWithTiming(id));
topChecks.push(checkServiceWithTiming('internet'));
const topResults = await Promise.all(topChecks);
dnsIds.forEach((id, i) => setQuick(id, topResults[i].isUp, topResults[i].responseTime));
const internetResult = topResults[topResults.length - 1];
setQuick('internet', internetResult.isUp, internetResult.responseTime);
// Check app services with timing
const appResults = await Promise.all(
window.APPS.map(async s => {
const result = await checkServiceWithTiming(s.id);
return { id: s.id, ...result };
})
);
appResults.forEach(result => {
setBadge(result.id, result.isUp, result.responseTime);
});
const stamp = document.getElementById('stamp');
if (stamp) stamp.textContent = 'last check: ' + new Date().toLocaleTimeString();
}
// DNS open buttons — use event delegation on .top container
document.querySelector('.top')?.addEventListener('click', (e) => {
const openBtn = e.target.closest('[id$="-open"]');
if (!openBtn) return;
const id = openBtn.id.replace('-open', '');
if (SITE.dnsServers[id]) window.open(serviceUrl(id), '_blank', 'noopener');
});
document.getElementById('ca-open')?.addEventListener('click', () => window.open(serviceUrl('ca'), '_blank', 'noopener'));
document.getElementById('creds-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceCredsModal) window.openServiceCredsModal(s); });
document.getElementById('options-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); const s = window.APPS.find(a => a.id === 'ca'); if (s && window.openServiceEditModal) window.openServiceEditModal(s); });
document.getElementById('delete-btn-ca')?.addEventListener('click', (e) => { e.stopPropagation(); if (window.deleteService) window.deleteService('ca', 'DashCA'); });
// Window exports
window.loadServices = loadServices;
window.buildGrid = buildGrid;
window.refreshAll = refreshAll;
window.setQuick = setQuick;
window.setBadge = setBadge;
window.getResponseTimeClass = getResponseTimeClass;
window.checkServiceWithTiming = checkServiceWithTiming;
window.serviceUrl = serviceUrl;
window.el = el;
})();

204
status/js/core/init.js Normal file
View File

@@ -0,0 +1,204 @@
// ========== DASHBOARD INITIALIZATION ==========
(function () {
function loadCustomServices() {
const customServices = safeGet('custom-services');
if (customServices) {
try {
const services = JSON.parse(customServices);
// Merge with default APPS, avoiding duplicates
services.forEach(service => {
if (!window.APPS.find(app => app.id === service.id)) {
window.APPS.push(service);
}
});
} catch (e) {
console.warn('Failed to load custom services:', e);
}
}
}
// Initialize custom services immediately so window.APPS is populated before buildGrid runs
loadCustomServices();
// Staggered animation for top cards too
function animateTopCards() {
const topCards = document.querySelectorAll('.top .card');
topCards.forEach((card, index) => {
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';
setTimeout(() => {
card.style.transition = 'opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)';
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, index * 150); // 150ms delay between top cards
});
}
// Initialize dashboard (called after TOTP gate check or directly if TOTP disabled)
// NOTE: loadServices comes from window.loadServices (exported by grid.js)
let _dashboardInitialized = false;
async function initializeDashboard() {
if (_dashboardInitialized) {
console.warn('[init] initializeDashboard called again, skipping duplicate');
return;
}
_dashboardInitialized = true;
await window.loadServices();
window.buildGrid();
animateTopCards();
window.refreshAll();
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
if (typeof window._updateAuthCard === 'function') {
try {
const r = await fetch('/api/v1/totp/config', { cache: 'no-store' });
const d = await r.json();
if (d.success) window._updateAuthCard(d.config.enabled && d.config.isSetUp, d.config.sessionDuration);
} catch (e) { /* ignore */ }
}
// Lazy-load onboarding for first-time users, otherwise just add the tour button
addTourButton();
if (shouldLoadOnboarding()) {
loadOnboarding();
}
}
// Lazy-load onboarding bundle (52 KB) — only loaded when needed
function loadOnboarding() {
if (document.querySelector('script[src="/dist/onboarding.js"]')) return; // already loading/loaded
const s = document.createElement('script');
s.src = '/dist/onboarding.js';
s.defer = true;
document.head.appendChild(s);
// Also load onboarding CSS if not already present
if (!document.querySelector('link[href="/css/driver.min.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/driver.min.css';
document.head.appendChild(link);
}
if (!document.querySelector('link[href="/css/onboarding.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/onboarding.css';
document.head.appendChild(link);
}
}
// Check if onboarding should auto-start (first-time user)
function shouldLoadOnboarding() {
try {
const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding'));
return !data || (!data.tourCompleted && data.currentStep === 0);
} catch (_) {
return true; // No data means first-time user
}
}
// ===== Collapsible toolbar sections =====
function initToolbarSections() {
const sections = document.querySelectorAll('.tools-section');
if (!sections.length) return;
// Restore saved state from localStorage
let saved = {};
try { saved = JSON.parse(localStorage.getItem('toolbar-sections') || '{}'); } catch (_) {}
sections.forEach(section => {
const key = section.dataset.section;
const header = section.querySelector('.tools-section-header');
if (!header) return;
// Restore state (default: collapsed)
if (saved[key]) {
section.classList.add('open');
header.setAttribute('aria-expanded', 'true');
}
header.addEventListener('click', (e) => {
e.preventDefault();
const isOpen = section.classList.toggle('open');
header.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
// Save state
const state = {};
document.querySelectorAll('.tools-section').forEach(s => {
state[s.dataset.section] = s.classList.contains('open');
});
localStorage.setItem('toolbar-sections', JSON.stringify(state));
});
});
}
// Initialize toolbar sections on DOM ready
initToolbarSections();
// Add restart tour button (loads bundle on click if not loaded)
// Visible in primary toolbar until tour completed once, then moves to Admin section
function addTourButton() {
if (document.getElementById('restart-tour-btn')) return;
// Check if tour has been completed before
let tourDone = false;
try {
const data = JSON.parse(localStorage.getItem('dashcaddy_onboarding'));
tourDone = data && data.tourCompleted;
} catch (_) {}
// Before first completion: show in primary toolbar. After: tuck into Admin section.
const target = tourDone
? document.querySelector('.tools-section[data-section="admin"] .tools-section-items')
: document.querySelector('.tools-primary');
if (!target) return;
const button = document.createElement('button');
button.id = 'restart-tour-btn';
button.textContent = tourDone ? 'Help Tour' : '🎓 Help Tour';
button.title = 'Restart the onboarding tour';
button.onclick = () => {
if (window.DashCaddyOnboarding) {
window.DashCaddyOnboarding.restartTour();
} else {
loadOnboarding();
// Wait for bundle to load, then start
const check = setInterval(() => {
if (window.DashCaddyOnboarding) {
clearInterval(check);
window.DashCaddyOnboarding.restartTour();
}
}, 100);
setTimeout(() => clearInterval(check), 5000); // give up after 5s
}
};
target.appendChild(button);
}
window.initializeDashboard = initializeDashboard;
window.loadCustomServices = loadCustomServices;
// TOTP-gated initialization
(async () => {
try {
const totpRes = await fetch('/api/v1/totp/config', { cache: 'no-store' });
const totpData = await totpRes.json();
if (totpData.success && totpData.config.enabled) {
// TOTP is enabled - check if we have a valid session
const testRes = await fetch('/api/v1/totp/check-session', { cache: 'no-store' });
if (testRes.status === 401) {
// Need TOTP verification - show overlay
window._showTotpOverlay();
return; // initializeDashboard() will be called after successful verification
}
}
} catch (e) {
console.warn('TOTP check failed, proceeding normally:', e);
}
// TOTP disabled or session valid - initialize immediately
initializeDashboard();
})();
})();

672
status/js/core/logs.js Normal file
View File

@@ -0,0 +1,672 @@
// ========== LOG VIEWERS ==========
(function () {
// Inject logs-modal HTML
injectModal('logs-modal', `
<div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header">
<h3 id="logs-title">DNS Logs</h3>
<div class="logs-controls">
<label for="log-lines">Show:</label>
<select id="log-lines">
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<button id="logs-stream" class="stream-btn" title="Enable real-time streaming">📡 Live</button>
<button id="logs-pause" class="pause-btn">⏸️ Pause</button>
<button id="logs-close" class="close-btn">✕</button>
</div>
</div>
<div class="logs-container scroll-container">
<div id="logs-content" class="logs-content">
<div class="logs-loading">Loading logs...</div>
</div>
</div>
</div>
</div>`);
// ===== State =====
let currentDnsService = null;
let logsInterval = null;
let logsPaused = false;
let currentContainerId = null;
let currentContainerName = null;
let containerLogsMode = false;
let currentLogPath = null;
let currentLogServiceName = null;
let fileLogsMode = false;
let logEventSource = null;
let isStreaming = false;
// ===== DNS LOGS =====
async function fetchDnsLogs(dnsId, lines = 25) {
try {
const serverIP = getDnsServerAddr(dnsId);
const response = await fetch(`/api/v1/dns/logs?server=${serverIP}&limit=${lines}`, {
cache: 'no-store',
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
});
if (response.ok) {
const result = await response.json();
if (result.success && result.logs) {
return { logs: result.logs, count: result.count, server: result.server };
} else {
return { error: result.error || 'Failed to fetch logs' };
}
} else if (response.status === 401) {
return { error: 'DNS auto-auth failed - check credentials in settings' };
} else {
return { error: `HTTP ${response.status}` };
}
} catch (error) {
console.error('DNS logs fetch failed:', error);
return { error: error.message };
}
}
function getRcodeColor(rcode) {
const colors = {
'NoError': 'var(--ok-fg)',
'NOERROR': 'var(--ok-fg)',
'NxDomain': 'var(--muted)',
'NXDOMAIN': 'var(--muted)',
'Refused': 'var(--bad-fg)',
'REFUSED': 'var(--bad-fg)',
'ServerFailure': '#f39c12',
'SERVFAIL': '#f39c12'
};
return colors[rcode] || 'var(--fg)';
}
function renderDnsLogEntry(log) {
const div = document.createElement('div');
div.className = 'log-entry';
div.style.cssText = 'display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;';
// If unparsed raw log
if (log.parsed === false) {
div.style.gridTemplateColumns = '1fr';
div.innerHTML = `<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(log.raw)}</span>`;
return div;
}
const rcodeColor = getRcodeColor(log.rcode);
const isBlocked = log.rcode === 'Refused' || log.rcode === 'REFUSED';
div.innerHTML = `
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(log.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(log.client)}">${escapeHtml(log.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${isBlocked ? 'text-decoration: line-through; opacity: 0.6;' : ''}" title="${escapeHtml(log.domain)}">${escapeHtml(log.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(log.type)}</span>
<span style="color: ${rcodeColor}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(log.rcode)}</span>
`;
return div;
}
async function updateLogsDisplay() {
// Handle file logs mode
if (fileLogsMode) {
await updateFileLogsDisplay();
return;
}
// Handle container logs mode
if (containerLogsMode) {
await updateContainerLogsDisplay();
return;
}
// Handle DNS logs mode
if (logsPaused || !currentDnsService) return;
const lines = parseInt(document.getElementById('log-lines').value);
const logsContent = document.getElementById('logs-content');
try {
const result = await fetchDnsLogs(currentDnsService, lines);
if (result.error) {
logsContent.innerHTML = `
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
<div>${result.error}</div>
</div>`;
return;
}
// Add header row
logsContent.innerHTML = `
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span>Time</span>
<span>Client</span>
<span>Domain</span>
<span>Type</span>
<span>Status</span>
</div>`;
if (result.logs && result.logs.length > 0) {
result.logs.forEach(log => {
const logElement = renderDnsLogEntry(log);
logsContent.appendChild(logElement);
});
} else {
logsContent.innerHTML += `
<div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet
</div>`;
}
} catch (error) {
logsContent.innerHTML = `
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${error.message}
</div>`;
}
}
function openLogsModal(dnsId) {
currentDnsService = dnsId;
logsPaused = false;
containerLogsMode = false;
const modal = document.getElementById('logs-modal');
const title = document.getElementById('logs-title');
const pauseBtn = document.getElementById('logs-pause');
const streamBtn = document.getElementById('logs-stream');
title.textContent = `${dnsId.toUpperCase()} DNS Logs`;
pauseBtn.textContent = '⏸️ Pause';
pauseBtn.classList.remove('paused');
// Hide stream button for DNS logs (only available for container logs)
if (streamBtn) {
streamBtn.style.display = 'none';
}
modal.classList.add('show');
// Initial load
updateLogsDisplay();
// Start auto-refresh every 3 seconds
logsInterval = setInterval(updateLogsDisplay, DC.POLL.LOGS);
}
function closeLogsModal() {
const modal = document.getElementById('logs-modal');
modal.classList.remove('show');
if (logsInterval) {
clearInterval(logsInterval);
logsInterval = null;
}
// Stop SSE streaming if active
stopLogStreaming();
// Reset all log modes
currentDnsService = null;
containerLogsMode = false;
currentContainerId = null;
currentContainerName = null;
fileLogsMode = false;
currentLogPath = null;
currentLogServiceName = null;
logsPaused = false;
}
// ===== SSE LOG STREAMING =====
function startLogStreaming(containerId) {
if (logEventSource) {
stopLogStreaming();
}
const streamBtn = document.getElementById('logs-stream');
const pauseBtn = document.getElementById('logs-pause');
const logsContent = document.getElementById('logs-content');
// Stop interval-based refresh
if (logsInterval) {
clearInterval(logsInterval);
logsInterval = null;
}
try {
logEventSource = new EventSource(`/api/v1/logs/stream/${containerId}`);
isStreaming = true;
streamBtn.classList.add('active');
streamBtn.textContent = '🔴 Live';
streamBtn.title = 'Streaming - click to stop';
pauseBtn.style.display = 'none';
// Add streaming indicator to header
const title = document.getElementById('logs-title');
if (!title.textContent.includes('🔴')) {
title.innerHTML = title.textContent.replace('📋', '📋 🔴');
}
logEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.error) {
console.error('Stream error:', data.error);
stopLogStreaming();
return;
}
// Append new log entry
const entry = document.createElement('div');
entry.className = 'log-entry';
entry.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;';
const streamType = data.stream || 'stdout';
const isError = streamType === 'stderr';
const levelColor = isError ? 'var(--bad-fg)' : 'var(--fg)';
const bgColor = isError ? 'var(--bad-bg)' : 'var(--ok-bg)';
const levelBadge = `<span style="background: ${bgColor}; color: ${levelColor}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${isError ? 'STDERR' : 'STDOUT'}</span>`;
entry.innerHTML = `
<div style="flex-shrink: 0;">${levelBadge}</div>
<div style="flex: 1; color: ${levelColor}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(data.text)}</div>
`;
logsContent.appendChild(entry);
// Auto-scroll to bottom
logsContent.scrollTop = logsContent.scrollHeight;
// Limit entries to prevent memory issues (keep last 500)
while (logsContent.children.length > 500) {
logsContent.removeChild(logsContent.firstChild);
}
} catch (err) {
console.error('Error parsing stream data:', err);
}
};
logEventSource.onerror = (err) => {
console.error('EventSource error:', err);
stopLogStreaming();
};
} catch (err) {
console.error('Failed to start streaming:', err);
stopLogStreaming();
}
}
function stopLogStreaming() {
if (logEventSource) {
logEventSource.close();
logEventSource = null;
}
isStreaming = false;
const streamBtn = document.getElementById('logs-stream');
const pauseBtn = document.getElementById('logs-pause');
const title = document.getElementById('logs-title');
if (streamBtn) {
streamBtn.classList.remove('active');
streamBtn.textContent = '📡 Live';
streamBtn.title = 'Enable real-time streaming';
}
if (pauseBtn) {
pauseBtn.style.display = '';
}
if (title) {
title.textContent = title.textContent.replace(' 🔴', '');
}
// Restart interval-based refresh if container logs modal is open
if (containerLogsMode && currentContainerId && !logsInterval) {
logsInterval = setInterval(updateContainerLogsDisplay, DC.POLL.LOGS);
}
}
// ===== CONTAINER LOGS =====
async function fetchContainerLogs(containerId, lines = 100) {
try {
const endpoint = `/api/v1/logs/container/${containerId}?tail=${lines}&timestamps=true`;
const response = await fetch(endpoint, {
cache: 'no-store',
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
});
if (response.ok) {
const result = await response.json();
if (result.success && result.logs) {
return {
logs: result.logs,
count: result.count,
containerName: result.containerName,
containerId: result.containerId
};
} else {
return { error: result.error || 'Failed to fetch container logs' };
}
} else {
return { error: `HTTP ${response.status}: ${response.statusText}` };
}
} catch (error) {
console.error('Container logs fetch failed:', error);
return { error: error.message };
}
}
function renderContainerLogEntry(log) {
const div = document.createElement('div');
div.className = 'log-entry';
div.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;';
const streamColor = log.stream === 'stderr' ? 'var(--bad-fg)' : 'var(--fg)';
const streamBadge = log.stream === 'stderr' ?
'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>' :
'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';
div.innerHTML = `
<div style="flex-shrink: 0;">${streamBadge}</div>
<div style="flex: 1; color: ${streamColor}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(log.text)}</div>
`;
return div;
}
async function updateContainerLogsDisplay() {
if (logsPaused || !currentContainerId || !containerLogsMode) return;
const lines = parseInt(document.getElementById('log-lines').value);
const logsContent = document.getElementById('logs-content');
try {
const result = await fetchContainerLogs(currentContainerId, lines);
if (result.error) {
logsContent.innerHTML = `
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
<div>${result.error}</div>
</div>`;
return;
}
// Add header row
logsContent.innerHTML = `
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex-shrink: 0; width: 80px;">Stream</span>
<span style="flex: 1;">Log Output</span>
</div>`;
if (result.logs && result.logs.length > 0) {
result.logs.forEach(log => {
const logElement = renderContainerLogEntry(log);
logsContent.appendChild(logElement);
});
// Auto-scroll to bottom
logsContent.scrollTop = logsContent.scrollHeight;
} else {
logsContent.innerHTML += `
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container
</div>`;
}
} catch (error) {
logsContent.innerHTML = `
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${error.message}
</div>`;
}
}
function openContainerLogsModal(containerId, containerName) {
currentContainerId = containerId;
currentContainerName = containerName;
containerLogsMode = true;
fileLogsMode = false;
logsPaused = false;
// Stop any existing streaming
stopLogStreaming();
const modal = document.getElementById('logs-modal');
const title = document.getElementById('logs-title');
const pauseBtn = document.getElementById('logs-pause');
const streamBtn = document.getElementById('logs-stream');
title.textContent = `📋 ${containerName} - Container Logs`;
pauseBtn.textContent = '⏸️ Pause';
pauseBtn.classList.remove('paused');
// Show stream button for container logs
if (streamBtn) {
streamBtn.style.display = '';
}
modal.classList.add('show');
// Initial load
updateContainerLogsDisplay();
// Start auto-refresh every 3 seconds
logsInterval = setInterval(updateContainerLogsDisplay, DC.POLL.LOGS);
}
// ===== FILE-BASED LOGS (for native apps) =====
async function fetchFileLogs(logPath, lines = 100) {
try {
const endpoint = `/api/v1/logs/file?path=${encodeURIComponent(logPath)}&tail=${lines}`;
const response = await fetch(endpoint, {
cache: 'no-store',
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }
});
if (response.ok) {
const result = await response.json();
if (result.success && result.logs) {
return {
logs: result.logs,
count: result.count,
logPath: result.logPath,
totalLines: result.totalLines
};
} else {
return { error: result.error || 'Failed to fetch file logs' };
}
} else {
const result = await response.json().catch(() => ({}));
return { error: result.error || `HTTP ${response.status}` };
}
} catch (error) {
console.error('File logs fetch failed:', error);
return { error: error.message };
}
}
function renderFileLogEntry(log) {
const div = document.createElement('div');
div.className = 'log-entry';
div.style.cssText = 'display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;';
const text = log.text;
let logLevel = 'INFO';
let levelColor = 'var(--fg)';
if (text.match(/ERROR|FATAL|CRITICAL/i)) {
logLevel = 'ERROR';
levelColor = 'var(--bad-fg)';
} else if (text.match(/WARN|WARNING/i)) {
logLevel = 'WARN';
levelColor = '#f39c12';
} else if (text.match(/DEBUG/i)) {
logLevel = 'DEBUG';
levelColor = 'var(--muted)';
}
const bgColor = levelColor === 'var(--bad-fg)' ? 'var(--bad-bg)' : 'var(--ok-bg)';
const levelBadge = `<span style="background: ${bgColor}; color: ${levelColor}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${logLevel}</span>`;
div.innerHTML = `
<div style="flex-shrink: 0;">${levelBadge}</div>
<div style="flex: 1; color: ${levelColor}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(text)}</div>
`;
return div;
}
async function updateFileLogsDisplay() {
if (logsPaused || !currentLogPath || !fileLogsMode) return;
const lines = parseInt(document.getElementById('log-lines').value);
const logsContent = document.getElementById('logs-content');
try {
const result = await fetchFileLogs(currentLogPath, lines);
if (result.error) {
logsContent.innerHTML = `
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">⚠️ Error</div>
<div>${result.error}</div>
</div>`;
return;
}
logsContent.innerHTML = `
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex: 1;">Log Output (${result.count} of ${result.totalLines} lines)</span>
</div>`;
if (result.logs && result.logs.length > 0) {
result.logs.forEach(log => {
const logElement = renderFileLogEntry(log);
logsContent.appendChild(logElement);
});
logsContent.scrollTop = logsContent.scrollHeight;
} else {
logsContent.innerHTML += `
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file
</div>`;
}
} catch (error) {
logsContent.innerHTML = `
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${error.message}
</div>`;
}
}
function openFileLogsModal(logPath, serviceName) {
currentLogPath = logPath;
currentLogServiceName = serviceName;
fileLogsMode = true;
containerLogsMode = false;
logsPaused = false;
const modal = document.getElementById('logs-modal');
const title = document.getElementById('logs-title');
const pauseBtn = document.getElementById('logs-pause');
const streamBtn = document.getElementById('logs-stream');
title.textContent = `📋 ${serviceName} - Application Logs`;
pauseBtn.textContent = '⏸️ Pause';
pauseBtn.classList.remove('paused');
// Hide stream button for file logs (only available for container logs)
if (streamBtn) {
streamBtn.style.display = 'none';
}
modal.classList.add('show');
updateFileLogsDisplay();
logsInterval = setInterval(updateFileLogsDisplay, DC.POLL.LOGS);
}
// ===== EVENT LISTENERS =====
// DNS log buttons — event delegation for dynamic cards
document.querySelector('.top')?.addEventListener('click', (e) => {
const logsBtn = e.target.closest('[id$="-logs"]');
if (!logsBtn) return;
const dnsId = logsBtn.id.replace('-logs', '');
if (!SITE.dnsServers[dnsId]) return;
openLogsModal(dnsId);
});
document.getElementById('logs-close')?.addEventListener('click', closeLogsModal);
document.getElementById('logs-pause')?.addEventListener('click', () => {
logsPaused = !logsPaused;
const pauseBtn = document.getElementById('logs-pause');
if (logsPaused) {
pauseBtn.textContent = '▶️ Resume';
pauseBtn.classList.add('paused');
} else {
pauseBtn.textContent = '⏸️ Pause';
pauseBtn.classList.remove('paused');
updateLogsDisplay();
}
});
document.getElementById('log-lines')?.addEventListener('change', () => {
if (!logsPaused) {
updateLogsDisplay();
}
});
// Stream button for real-time SSE logs (only works with container logs)
document.getElementById('logs-stream')?.addEventListener('click', () => {
if (!containerLogsMode || !currentContainerId) {
// Streaming only available for container logs
return;
}
if (isStreaming) {
stopLogStreaming();
} else {
startLogStreaming(currentContainerId);
}
});
// Close modal on outside click
document.getElementById('logs-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'logs-modal') {
closeLogsModal();
}
});
// Close logs-modal on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (document.getElementById('logs-modal')?.classList.contains('show')) {
closeLogsModal();
}
}
});
// ===== EXPORTS =====
window.openContainerLogsModal = openContainerLogsModal;
window.openFileLogsModal = openFileLogsModal;
window.openLogsModal = openLogsModal;
})();

View File

@@ -0,0 +1,648 @@
// ========== SERVICE CREATION ==========
// Add service modal, local/external service creation flows, and event wiring.
(function () {
// ===== SUBDOMAIN AUTO-DERIVE =====
function deriveSubdomain(name) {
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function getSmartSslDefault() {
return SITE.defaults?.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'caddy-managed');
}
// ===== SERVICE PREVIEW =====
function updateServicePreview() {
const subdomain = document.getElementById('service-subdomain-input').value || 'subdomain';
const ip = document.getElementById('service-ip-input').value || QUICK_IPS.lan || 'localhost';
const port = document.getElementById('service-port-input').value || DC.DEFAULTS.SERVICE_PORT;
const sslType = document.getElementById('ssl-type-select').value;
const caName = document.getElementById('ca-name-input').value || 'sami-ca';
const existingCa = document.getElementById('existing-ca-select').value;
const enableAuth = document.getElementById('enable-auth').checked;
const enableCors = document.getElementById('enable-cors').checked;
const customHeaders = document.getElementById('custom-headers-input').value;
const upstreamPath = document.getElementById('upstream-path-input').value || '/';
const healthCheck = document.getElementById('health-check-input').value;
const timeout = document.getElementById('timeout-input').value || 30;
const dnsPreview = document.getElementById('dns-preview');
if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`;
const urlPreview = document.getElementById('url-preview');
if (urlPreview) urlPreview.textContent = `https://${buildDomain(subdomain)}`;
const config = {
subdomain, port, ip, sslType, caName, existingCa,
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout
};
const caddyConfig = window.generateCaddyConfig(config);
const configPreview = document.getElementById('caddy-config-preview');
if (configPreview) configPreview.value = caddyConfig;
}
// ===== QUICK IP CONFIGURATION =====
const QUICK_IPS = {
localhost: '127.0.0.1',
lan: '',
tailscale: ''
};
async function detectNetworkIPs() {
try {
const response = await fetch('/api/v1/network/ips', {
signal: AbortSignal.timeout(2000)
});
if (response.ok) {
const data = await response.json();
if (data.lan) QUICK_IPS.lan = data.lan;
if (data.tailscale) QUICK_IPS.tailscale = data.tailscale;
}
} catch (e) {
// API not available
}
const lanBtn = document.getElementById('quick-ip-lan');
const tsBtn = document.getElementById('quick-ip-tailscale');
if (lanBtn) {
if (QUICK_IPS.lan) {
lanBtn.dataset.ip = QUICK_IPS.lan;
lanBtn.textContent = `LAN (${QUICK_IPS.lan})`;
lanBtn.title = `LAN IP: ${QUICK_IPS.lan}`;
} else {
lanBtn.style.display = 'none';
}
}
if (tsBtn) {
if (QUICK_IPS.tailscale) {
tsBtn.dataset.ip = QUICK_IPS.tailscale;
tsBtn.textContent = `Tailscale (${QUICK_IPS.tailscale})`;
tsBtn.title = `Tailscale IP: ${QUICK_IPS.tailscale}`;
} else {
tsBtn.style.display = 'none';
}
}
const ipInput = document.getElementById('service-ip-input');
if (ipInput && !ipInput.value && QUICK_IPS.lan) ipInput.value = QUICK_IPS.lan;
}
function initQuickIPButtons() {
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
btn.addEventListener('click', () => {
const ip = btn.dataset.ip;
if (ip) {
document.getElementById('service-ip-input').value = ip;
document.querySelectorAll('.quick-ip-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateServicePreview();
}
});
});
document.getElementById('service-ip-input')?.addEventListener('input', (e) => {
const currentIP = e.target.value;
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.ip === currentIP);
});
});
}
// ===== ADD SERVICE MODAL =====
async function openAddServiceModal() {
const modal = document.getElementById('add-service-modal');
modal.classList.add('show');
const modalContent = modal.querySelector('.weather-modal-content');
if (modalContent) modalContent.scrollTop = 0;
document.body.style.overflow = 'hidden';
// Set smart SSL default
const sslSelect = document.getElementById('ssl-type-select');
if (sslSelect) sslSelect.value = getSmartSslDefault();
await detectNetworkIPs();
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
await window.loadExistingCAs(caddyfilePath);
// Check Tailscale status
const tailscaleStatus = document.getElementById('manual-tailscale-status');
const tailscaleCheckbox = document.getElementById('manual-tailscale-only');
try {
const response = await fetch('/api/v1/tailscale/status');
const data = await response.json();
if (data.success && data.installed && data.connected) {
tailscaleStatus.innerHTML = `
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${data.self?.hostname} (${data.self?.ip})</span>
`;
tailscaleCheckbox.disabled = false;
} else if (data.installed) {
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">\u26A0 Not connected</span>`;
tailscaleCheckbox.disabled = true;
} else {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Not available</span>`;
tailscaleCheckbox.disabled = true;
}
} catch (e) {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check</span>`;
tailscaleCheckbox.disabled = true;
}
tailscaleCheckbox.checked = false;
updateServicePreview();
}
// ===== SERVICE TYPE SWITCHING (TAB STYLE) =====
function setupServiceTypeSwitching() {
const localRadio = document.getElementById('service-type-local');
const externalRadio = document.getElementById('service-type-external');
const localConfig = document.getElementById('local-service-config');
const externalConfig = document.getElementById('external-service-config');
const tabLocal = document.getElementById('tab-local');
const tabExternal = document.getElementById('tab-external');
function switchServiceType() {
if (localRadio.checked) {
localConfig.style.display = 'grid';
externalConfig.style.display = 'none';
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
} else {
localConfig.style.display = 'none';
externalConfig.style.display = 'block';
if (tabExternal) { tabExternal.style.background = 'var(--accent)'; tabExternal.style.color = 'var(--bg)'; }
if (tabLocal) { tabLocal.style.background = 'transparent'; tabLocal.style.color = 'var(--muted)'; }
}
}
localRadio?.addEventListener('change', switchServiceType);
externalRadio?.addEventListener('change', switchServiceType);
}
// ===== AUTO-DERIVE SUBDOMAIN FROM NAME =====
function setupAutoSubdomain() {
// Local service: name → subdomain + preview
const nameInput = document.getElementById('service-name-input');
const subdomainInput = document.getElementById('service-subdomain-input');
const subdomainPreview = document.getElementById('subdomain-preview');
let userEditedSubdomain = false;
nameInput?.addEventListener('input', () => {
const derived = deriveSubdomain(nameInput.value);
if (!userEditedSubdomain && subdomainInput) {
subdomainInput.value = derived;
}
if (subdomainPreview) {
subdomainPreview.textContent = derived ? `\u2192 ${buildDomain(derived)}` : '';
}
updateServicePreview();
});
subdomainInput?.addEventListener('input', () => {
userEditedSubdomain = subdomainInput.value !== deriveSubdomain(nameInput?.value || '');
const sub = subdomainInput.value.trim() || deriveSubdomain(nameInput?.value || '');
if (subdomainPreview) {
subdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
updateServicePreview();
});
// External service: name → subdomain + preview
const extNameInput = document.getElementById('external-service-name');
const extSubdomainInput = document.getElementById('external-service-subdomain');
const extSubdomainPreview = document.getElementById('external-subdomain-preview');
const extDomainPreview = document.getElementById('external-domain-preview');
let userEditedExtSubdomain = false;
extNameInput?.addEventListener('input', () => {
const derived = deriveSubdomain(extNameInput.value);
if (!userEditedExtSubdomain && extSubdomainInput) {
extSubdomainInput.value = derived;
}
const sub = extSubdomainInput?.value || derived;
if (extSubdomainPreview) {
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
if (extDomainPreview) {
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
}
});
extSubdomainInput?.addEventListener('input', () => {
userEditedExtSubdomain = extSubdomainInput.value !== deriveSubdomain(extNameInput?.value || '');
const sub = extSubdomainInput.value.trim() || deriveSubdomain(extNameInput?.value || '');
if (extSubdomainPreview) {
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
if (extDomainPreview) {
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
}
});
}
// ===== CREATE EXTERNAL SERVICE =====
async function createExternalService() {
const name = document.getElementById('external-service-name').value.trim();
const externalUrl = document.getElementById('external-service-url').value.trim();
const subdomain = (document.getElementById('external-service-subdomain').value.trim() || deriveSubdomain(name)).toLowerCase();
const logo = document.getElementById('external-service-logo').value.trim();
const icon = document.getElementById('external-service-icon').value.trim();
const createDns = document.getElementById('external-create-dns').checked;
const createCaddy = document.getElementById('external-create-caddy').checked;
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
const preserveHost = document.getElementById('external-preserve-host').checked;
const followRedirects = document.getElementById('external-follow-redirects').checked;
if (!name || !externalUrl) {
showNotification('Please fill in Name and External URL', 'warning');
return;
}
if (!subdomain) {
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
return;
}
if (!externalUrl.startsWith('http://') && !externalUrl.startsWith('https://')) {
showNotification('External URL must start with http:// or https://', 'warning');
return;
}
const domain = buildDomain(subdomain);
try {
const results = { dns: null, caddy: null, dashboard: false };
if (createDns) {
const adminToken = window.getToken('dns2', 'admin');
if (adminToken) {
try {
const dnsResponse = await secureFetch('/api/v1/dns/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: domain,
ip: proxyIp,
ttl: DC.DEFAULTS.TTL,
server: SITE.dnsIp
})
});
const dnsResult = await dnsResponse.json();
results.dns = dnsResult.success ? 'created' : (dnsResult.error || 'failed');
} catch (e) {
results.dns = e.message;
}
} else {
results.dns = 'no admin token (configure in \uD83D\uDD11 Tokens)';
}
}
if (createCaddy) {
try {
const caddyConfig = {
subdomain: subdomain,
externalUrl: externalUrl,
preserveHost: preserveHost,
followRedirects: followRedirects,
sslType: 'caddy-managed',
caddyfilePath: DC.DEFAULTS.CADDYFILE,
reloadCaddy: true
};
const caddyResponse = await secureFetch('/api/v1/site/external', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(caddyConfig)
});
const caddyResult = await caddyResponse.json();
results.caddy = caddyResult.success ? 'created' : (caddyResult.error || 'failed');
} catch (e) {
results.caddy = e.message;
}
}
const newService = {
id: subdomain,
name: name,
url: `https://${domain}`,
externalUrl: externalUrl,
logo: logo || icon || '\uD83C\uDF10',
isExternal: true,
isCustom: true
};
window.APPS.push(newService);
results.dashboard = true;
const defaultServices = ['plex', 'router', 'chat', 'sync', 'torrent', 'radarr', 'sonarr', 'prowlarr', 'portainer', 'requests', 'jellyfin', 'emby'];
const customServices = window.APPS.filter(app => !defaultServices.includes(app.id));
safeSet('custom-services', JSON.stringify(customServices));
try {
await secureFetch('/api/v1/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(window.APPS)
});
} catch (e) {
console.warn('Failed to save to services.json:', e);
}
window.buildGrid();
window.refreshAll();
closeAddServiceModal();
const parts = [`External service "${name}" added!`];
if (createDns) parts.push(`DNS: ${results.dns === 'created' ? '\u2713' : '\u26A0 ' + results.dns}`);
if (createCaddy) parts.push(`Caddy: ${results.caddy === 'created' ? '\u2713' : '\u26A0 ' + results.caddy}`);
parts.push(`Access at: https://${domain}`);
showNotification(parts.join(' | '), 'success', 6000);
} catch (error) {
console.error('Failed to create external service:', error);
showNotification(`Failed to create external service: ${error.message}`, 'error');
}
}
// ===== CLOSE ADD SERVICE MODAL =====
function closeAddServiceModal() {
closeModal('add-service-modal');
document.body.style.overflow = '';
document.getElementById('service-name-input').value = '';
document.getElementById('service-subdomain-input').value = '';
document.getElementById('service-port-input').value = '';
document.getElementById('service-ip-input').value = QUICK_IPS.lan || '';
document.getElementById('service-logo-input').value = '';
document.getElementById('dns-ttl-input').value = DC.DEFAULTS.TTL;
document.getElementById('ssl-type-select').value = getSmartSslDefault();
document.getElementById('ca-name-input').value = '';
document.getElementById('enable-auth').checked = false;
document.getElementById('enable-cors').checked = false;
document.getElementById('custom-headers-input').value = '';
document.getElementById('upstream-path-input').value = '/';
document.getElementById('health-check-input').value = '';
document.getElementById('timeout-input').value = '30';
// Clear subdomain previews
const subPrev = document.getElementById('subdomain-preview');
if (subPrev) subPrev.textContent = '';
const extSubPrev = document.getElementById('external-subdomain-preview');
if (extSubPrev) extSubPrev.textContent = '';
// Clear external fields
const extName = document.getElementById('external-service-name');
if (extName) extName.value = '';
const extSub = document.getElementById('external-service-subdomain');
if (extSub) extSub.value = '';
const extUrl = document.getElementById('external-service-url');
if (extUrl) extUrl.value = '';
const extLogo = document.getElementById('external-service-logo');
if (extLogo) extLogo.value = '';
const extIcon = document.getElementById('external-service-icon');
if (extIcon) extIcon.value = '';
// Collapse options
const localOpts = document.getElementById('local-advanced-options');
if (localOpts) localOpts.removeAttribute('open');
const extOpts = document.getElementById('external-advanced-options');
if (extOpts) extOpts.removeAttribute('open');
// Reset to local tab
const localRadio = document.getElementById('service-type-local');
if (localRadio) localRadio.checked = true;
const localConfig = document.getElementById('local-service-config');
const externalConfig = document.getElementById('external-service-config');
if (localConfig) localConfig.style.display = 'grid';
if (externalConfig) externalConfig.style.display = 'none';
const tabLocal = document.getElementById('tab-local');
const tabExternal = document.getElementById('tab-external');
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
}
// ===== CREATE NEW SERVICE =====
async function createNewService() {
const name = document.getElementById('service-name-input').value.trim();
const subdomain = (document.getElementById('service-subdomain-input').value.trim() || deriveSubdomain(name)).toLowerCase();
const port = document.getElementById('service-port-input').value.trim();
const ip = document.getElementById('service-ip-input').value.trim();
const logo = document.getElementById('service-logo-input').value.trim();
const createDns = document.getElementById('create-dns-record').checked;
const ttl = parseInt(document.getElementById('dns-ttl-input').value) || DC.DEFAULTS.TTL;
const tailscaleOnly = document.getElementById('manual-tailscale-only')?.checked || false;
const sslType = document.getElementById('ssl-type-select')?.value || 'caddy-managed';
const caName = document.getElementById('ca-name-input')?.value || '';
const existingCa = document.getElementById('existing-ca-select')?.value || '';
const enableAuth = document.getElementById('enable-auth')?.checked || false;
const enableCors = document.getElementById('enable-cors')?.checked || false;
const customHeaders = document.getElementById('custom-headers-input')?.value || '';
const upstreamPath = document.getElementById('upstream-path-input')?.value || '/';
const healthCheck = document.getElementById('health-check-input')?.value || '';
const timeout = document.getElementById('timeout-input')?.value || 30;
const dnsToken = window.getToken('dns2', 'admin');
if (!name || !port || !ip) {
showNotification('Please fill in Name, Port, and IP Address', 'warning');
return;
}
if (!subdomain) {
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
return;
}
if (createDns && !dnsToken) {
showNotification('DNS Admin token required. Configure it in the Tokens menu first.', 'warning');
return;
}
const results = { dns: null, caddy: null, dashboard: false };
try {
if (createDns) {
try {
await window.createDnsRecord(subdomain, ip, ttl);
results.dns = 'created';
} catch (error) {
console.error('DNS creation failed:', error);
results.dns = error.message;
throw new Error(`DNS creation failed: ${error.message}`);
}
} else {
results.dns = 'skipped';
}
const caddyConfig = window.generateCaddyConfig({
subdomain, port, ip, sslType, caName, existingCa,
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout, tailscaleOnly
});
try {
const caddyResponse = await secureFetch('/api/v1/site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: buildDomain(subdomain),
upstream: `${ip}:${port}`,
config: caddyConfig
})
});
const caddyResult = await caddyResponse.json();
if (caddyResult.success) {
results.caddy = 'added & reloaded';
} else {
console.error('Caddy configuration failed:', caddyResult.error);
results.caddy = caddyResult.error || 'failed';
throw new Error(`Caddy configuration failed: ${caddyResult.error}`);
}
} catch (error) {
console.error('Caddy API error:', error);
results.caddy = error.message;
throw new Error(`Caddy API error: ${error.message}`);
}
const serviceConfig = {
name, subdomain, port, ip,
logo: logo || `/assets/${subdomain}.png`,
tailscaleOnly: tailscaleOnly || false
};
await window.addServiceToConfig(serviceConfig);
results.dashboard = true;
const statusParts = [
`DNS: ${results.dns === 'created' ? '\u2713' : results.dns === 'skipped' ? '\u25CB' : '\u2717'}`,
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
`Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}`
];
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 https://${buildDomain(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
closeAddServiceModal();
window.buildGrid();
window.refreshAll();
} catch (error) {
console.error('Error creating service:', error);
showNotification(`Error creating "${name}": ${error.message}`, 'error', 6000);
}
}
// ===== EVENT LISTENERS =====
document.getElementById('add-service')?.addEventListener('click', openAddServiceModal);
document.getElementById('add-service-cancel')?.addEventListener('click', closeAddServiceModal);
document.getElementById('add-service-create')?.addEventListener('click', () => {
const serviceType = document.querySelector('input[name="service-type"]:checked')?.value;
if (serviceType === 'external') {
createExternalService();
} else {
createNewService();
}
});
setupServiceTypeSwitching();
setupAutoSubdomain();
initQuickIPButtons();
// SSL type change handler
document.getElementById('ssl-type-select')?.addEventListener('change', (e) => {
const existingCaConfig = document.getElementById('existing-ca-config');
const customCaConfig = document.getElementById('custom-ca-config');
existingCaConfig.style.display = 'none';
customCaConfig.style.display = 'none';
if (e.target.value === 'existing-ca') {
existingCaConfig.style.display = 'block';
} else if (e.target.value === 'custom-ca') {
customCaConfig.style.display = 'block';
}
updateServicePreview();
});
// Refresh CAs button
document.getElementById('refresh-cas')?.addEventListener('click', async () => {
const button = document.getElementById('refresh-cas');
const originalText = button.textContent;
button.textContent = '\u231B Loading...';
button.disabled = true;
try {
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
await window.loadExistingCAs(caddyfilePath);
button.textContent = '\u2705 Refreshed';
} catch (error) {
button.textContent = '\u274C Failed';
console.error('Failed to refresh CAs:', error);
}
setTimeout(() => {
button.textContent = originalText;
button.disabled = false;
}, 2000);
});
// DNS record checkbox handler
document.getElementById('create-dns-record')?.addEventListener('change', (e) => {
const dnsConfig = document.getElementById('dns-config');
dnsConfig.style.display = e.target.checked ? 'block' : 'none';
});
// Real-time preview updates
['service-subdomain-input', 'service-ip-input', 'service-port-input', 'ca-name-input',
'existing-ca-select', 'enable-auth', 'enable-cors', 'custom-headers-input', 'upstream-path-input',
'health-check-input', 'timeout-input'].forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('input', updateServicePreview);
element.addEventListener('change', updateServicePreview);
}
});
// ===== CUSTOM SERVICES FROM LOCALSTORAGE =====
function loadCustomServices() {
const customServices = safeGet('custom-services');
if (customServices) {
try {
const services = JSON.parse(customServices);
services.forEach(service => {
if (!window.APPS.find(app => app.id === service.id)) {
window.APPS.push(service);
}
});
} catch (e) {
console.warn('Failed to load custom services:', e);
}
}
}
loadCustomServices();
// ===== WINDOW EXPORTS =====
window.openAddServiceModal = openAddServiceModal;
window.closeAddServiceModal = closeAddServiceModal;
})();

View File

@@ -0,0 +1,429 @@
// ========== SERVICE CRUD ==========
// Edit, delete, and update operations for existing services.
(function () {
// ===== SERVICE EDIT MODAL =====
let currentEditService = null;
function openServiceEditModal(service) {
currentEditService = service;
const modal = document.getElementById('service-edit-modal');
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
document.getElementById('edit-service-name-display').textContent = service.name;
document.getElementById('edit-service-url-display').textContent = `https://${buildDomain(service.id)}`;
document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
document.getElementById('edit-subdomain').value = service.id;
document.getElementById('edit-port').value = service.port || '';
document.getElementById('edit-ip').value = service.ip || 'localhost';
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
document.getElementById('edit-logo-url').value = service.logo || '';
modal.classList.add('show');
}
function closeServiceEditModal() {
closeModal('service-edit-modal');
currentEditService = null;
}
async function saveServiceChanges() {
if (!currentEditService) return;
const newSubdomain = document.getElementById('edit-subdomain').value.trim().toLowerCase();
const newPort = document.getElementById('edit-port').value.trim();
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
const newLogo = document.getElementById('edit-logo-url').value.trim();
if (!newSubdomain) {
showNotification('Subdomain is required', 'warning');
return;
}
const oldSubdomain = currentEditService.id;
const changes = [];
if (newSubdomain !== oldSubdomain) changes.push('subdomain');
if (newPort && newPort !== String(currentEditService.port)) changes.push('port');
if (newIp !== currentEditService.ip) changes.push('ip');
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
if (newLogo !== currentEditService.logo) changes.push('logo');
if (changes.length === 0) {
closeServiceEditModal();
return;
}
const saveBtn = document.getElementById('service-edit-save');
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
if (changes.includes('subdomain') || changes.includes('port') || changes.includes('ip') || changes.includes('tailscale')) {
const response = await secureFetch('/api/v1/services/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldSubdomain,
newSubdomain,
port: newPort || currentEditService.port,
ip: newIp,
tailscaleOnly
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to update service');
}
}
// Update local APPS array
const appIndex = window.APPS.findIndex(a => a.id === oldSubdomain);
if (appIndex !== -1) {
window.APPS[appIndex] = {
...window.APPS[appIndex],
id: newSubdomain,
port: newPort || window.APPS[appIndex].port,
ip: newIp,
tailscaleOnly,
logo: newLogo || window.APPS[appIndex].logo
};
}
// Update services via API
await secureFetch('/api/v1/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: newSubdomain,
name: currentEditService.name,
port: newPort || currentEditService.port,
ip: newIp,
logo: newLogo || currentEditService.logo,
tailscaleOnly,
containerId: currentEditService.containerId,
appTemplate: currentEditService.appTemplate
})
});
// If subdomain changed, remove old entry
if (newSubdomain !== oldSubdomain) {
await secureFetch(`/api/v1/services/${oldSubdomain}`, { method: 'DELETE' });
}
closeServiceEditModal();
window.buildGrid();
window.refreshAll();
} catch (error) {
console.error('Error saving service changes:', error);
showNotification(`Error saving changes: ${error.message}`, 'error');
} finally {
saveBtn.textContent = 'Save Changes';
saveBtn.disabled = false;
}
}
// Logo file upload handler
document.getElementById('edit-logo-file')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('Please select an image file', 'warning');
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const dataUrl = event.target.result;
document.getElementById('edit-service-logo-preview').src = dataUrl;
document.getElementById('edit-logo-url').value = dataUrl;
if (currentEditService) {
try {
const response = await secureFetch('/api/v1/assets/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: `${currentEditService.id}.png`,
data: dataUrl
})
});
const result = await response.json();
if (result.success && result.path) {
document.getElementById('edit-logo-url').value = result.path;
}
} catch (err) {
// Fallback to data URL
}
}
};
reader.readAsDataURL(file);
});
// Service edit modal event listeners
document.getElementById('service-edit-cancel')?.addEventListener('click', closeServiceEditModal);
document.getElementById('service-edit-save')?.addEventListener('click', saveServiceChanges);
document.getElementById('service-edit-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'service-edit-modal') closeServiceEditModal();
});
// ===== DELETE SERVICE MODAL =====
function showDeleteModal(serviceName, hasContainer, containerId) {
return new Promise((resolve) => {
const modal = document.getElementById('delete-service-modal');
const title = document.getElementById('delete-modal-title');
const message = document.getElementById('delete-modal-message');
const containerInfo = document.getElementById('delete-modal-container-info');
const containerName = document.getElementById('delete-modal-container-name');
const help = document.getElementById('delete-modal-help');
const cancelBtn = document.getElementById('delete-modal-cancel');
const removeBtn = document.getElementById('delete-modal-remove');
const deleteBtn = document.getElementById('delete-modal-delete');
title.textContent = `Delete "${serviceName}"`;
if (hasContainer) {
message.innerHTML = 'This service has an associated Docker container.<br>Choose how to proceed:';
containerInfo.style.display = 'block';
containerName.textContent = `Container ID: ${containerId?.slice(0, 12) || 'Unknown'}`;
help.style.display = 'block';
deleteBtn.style.display = 'block';
} else {
message.textContent = 'Remove this service from the dashboard?';
containerInfo.style.display = 'none';
help.style.display = 'none';
deleteBtn.style.display = 'none';
}
const cleanup = () => {
modal.classList.remove('show');
cancelBtn.removeEventListener('click', handleCancel);
removeBtn.removeEventListener('click', handleRemove);
deleteBtn.removeEventListener('click', handleDelete);
modal.removeEventListener('click', handleBackdrop);
};
const handleCancel = () => { cleanup(); resolve(null); };
const handleRemove = () => { cleanup(); resolve(false); };
const handleDelete = () => { cleanup(); resolve(true); };
const handleBackdrop = (e) => { if (e.target === modal) { cleanup(); resolve(null); } };
cancelBtn.addEventListener('click', handleCancel);
removeBtn.addEventListener('click', handleRemove);
deleteBtn.addEventListener('click', handleDelete);
modal.addEventListener('click', handleBackdrop);
modal.classList.add('show');
});
}
// ===== UPDATE CONTAINER =====
async function updateContainer(containerId, serviceName, serviceId) {
const updateBtn = document.getElementById(`update-btn-${serviceId}`);
const originalText = updateBtn?.textContent;
if (!confirm(`Update ${serviceName} to the latest version?\n\nThis will:\n1. Pull the latest image\n2. Stop the container\n3. Recreate with same settings\n\nThe service will be briefly unavailable.`)) {
return;
}
try {
if (updateBtn) {
updateBtn.textContent = '\u{1F504}';
updateBtn.disabled = true;
updateBtn.title = 'Updating...';
}
const response = await secureFetch(`/api/v1/containers/${containerId}/update`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
const service = window.APPS.find(app => app.id === serviceId);
if (service && result.newContainerId) {
service.containerId = result.newContainerId;
}
if (updateBtn) {
updateBtn.textContent = '\u{2705}';
updateBtn.title = 'Updated successfully!';
setTimeout(() => {
updateBtn.textContent = originalText;
updateBtn.disabled = false;
updateBtn.title = 'Update container to latest version';
}, 3000);
}
setTimeout(() => window.refreshAll(), 2000);
showNotification(`${serviceName} updated successfully!`, 'success');
} else {
throw new Error(result.error || 'Update failed');
}
} catch (error) {
console.error('Update error:', error);
if (updateBtn) {
updateBtn.textContent = '\u{274C}';
updateBtn.title = 'Update failed';
setTimeout(() => {
updateBtn.textContent = originalText;
updateBtn.disabled = false;
updateBtn.title = 'Update container to latest version';
}, 3000);
}
showNotification(`Failed to update ${serviceName}: ${error.message}`, 'error');
}
}
// ===== DELETE SERVICE =====
async function deleteService(serviceId, serviceName) {
const service = window.APPS.find(app => app.id === serviceId);
const domain = service ? buildDomain(service.id) : null;
const hasContainer = service?.containerId;
const deleteContainer = await showDeleteModal(serviceName || serviceId, hasContainer, service?.containerId);
if (deleteContainer === null) {
return; // User cancelled
}
let results = {
dashboard: false,
container: null,
dns: null,
caddy: null,
service: null
};
// Full removal with container
if (deleteContainer && hasContainer) {
try {
const params = new URLSearchParams({
containerId: service.containerId,
subdomain: service.id,
ip: service.ip || 'localhost',
deleteContainer: 'true'
});
const response = await secureFetch(`/api/v1/apps/${encodeURIComponent(service.id)}?${params.toString()}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
results = { ...results, ...result.results, dashboard: false };
} else {
console.error('App removal failed:', result.error);
}
} catch (error) {
console.error('App removal error:', error);
}
} else if (deleteContainer && domain) {
// Fallback for manually added services
try {
const serviceIP = service?.ip || 'localhost';
const dnsResponse = await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(domain)}&type=A&ipAddress=${encodeURIComponent(serviceIP)}&server=${SITE.dnsIp}`, {
method: 'DELETE'
});
const dnsResult = await dnsResponse.json();
results.dns = dnsResult.success ? 'deleted' : (dnsResult.error || 'failed');
} catch (e) {
results.dns = e.message;
}
try {
const caddyResponse = await secureFetch(`/api/v1/site/${encodeURIComponent(domain)}`, {
method: 'DELETE'
});
const caddyResult = await caddyResponse.json();
results.caddy = (caddyResult.success || (caddyResult.error && caddyResult.error.includes('not found'))) ? 'removed' : (caddyResult.error || 'failed');
} catch (e) {
results.caddy = e.message;
}
}
// Remove from APPS array
const index = window.APPS.findIndex(app => app.id === serviceId);
if (index > -1) {
window.APPS.splice(index, 1);
results.dashboard = true;
}
// Remove from localStorage
try {
const customApps = safeGetJSON('custom-apps', []);
const localIndex = customApps.findIndex(app => app.id === serviceId);
if (localIndex > -1) {
customApps.splice(localIndex, 1);
safeSet('custom-apps', JSON.stringify(customApps));
}
} catch (e) {
// Ignore localStorage errors
}
// Remove from services.json via API
try {
const serviceResponse = await secureFetch(`/api/v1/services/${encodeURIComponent(serviceId)}`, {
method: 'DELETE'
});
const serviceResult = await serviceResponse.json();
results.service = serviceResult.success ? 'removed' : (serviceResult.error || 'failed');
} catch (e) {
results.service = e.message;
}
window.buildGrid();
window.refreshAll();
// Only show alert if there are actual errors
let hasErrors = false;
let errorMessages = [];
if (!results.dashboard) {
hasErrors = true;
errorMessages.push('\u{2717} Failed to remove from dashboard');
}
const successStates = ['removed', 'already removed', 'not found', 'deleted', 'kept (user choice)', 'skipped', 'no such record', 'does not exist'];
const isSuccess = (val) => !val || successStates.some(s => val.toLowerCase().includes(s.toLowerCase()));
if (results.container && !isSuccess(results.container)) {
hasErrors = true;
errorMessages.push(`\u{26A0} Container: ${results.container}`);
}
if (results.dns && !isSuccess(results.dns)) {
hasErrors = true;
errorMessages.push(`\u{26A0} DNS Record: ${results.dns}`);
}
if (results.caddy && !isSuccess(results.caddy)) {
hasErrors = true;
errorMessages.push(`\u{26A0} Caddy Config: ${results.caddy}`);
}
if (results.service && !isSuccess(results.service)) {
hasErrors = true;
errorMessages.push(`\u{26A0} Service File: ${results.service}`);
}
if (hasErrors) {
showNotification(`Error deleting "${serviceName || serviceId}": ${errorMessages.join(', ')}`, 'error', 6000);
}
}
// ===== WINDOW EXPORTS =====
window.openServiceEditModal = openServiceEditModal;
window.showDeleteModal = showDeleteModal;
window.updateContainer = updateContainer;
window.deleteService = deleteService;
})();

View File

@@ -0,0 +1,245 @@
// ========== SERVICE INFRASTRUCTURE ==========
// Caddy config generation, DNS record creation, and service registration.
(function () {
// ===== LOAD EXISTING CAs =====
async function loadExistingCAs(caddyfilePath) {
try {
const response = await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(caddyfilePath)}`);
if (!response.ok) {
throw new Error(`Failed to load CAs: ${response.status}`);
}
const result = await response.json();
if (result.status === 'success') {
const select = document.getElementById('existing-ca-select');
select.innerHTML = '';
if (result.data.cas.length === 0) {
select.innerHTML = '<option value="">No CAs found in Caddyfile</option>';
} else {
select.innerHTML = '<option value="">Select existing CA...</option>';
result.data.cas.forEach(ca => {
const option = document.createElement('option');
if (typeof ca === 'object') {
option.value = ca.id;
option.textContent = ca.displayName || ca.name;
} else {
option.value = ca;
option.textContent = ca;
}
select.appendChild(option);
});
}
return result.data.cas;
} else {
throw new Error(result.message);
}
} catch (error) {
console.error('Error loading CAs:', error);
const select = document.getElementById('existing-ca-select');
select.innerHTML = '<option value="">Error loading CAs</option>';
return [];
}
}
// ===== GENERATE CADDY CONFIG =====
function generateCaddyConfig(config) {
const {
subdomain,
port,
ip,
sslType,
caName,
existingCa,
enableAuth,
enableCors,
customHeaders,
upstreamPath,
healthCheck,
timeout,
tailscaleOnly
} = config;
let caddyConfig = `${buildDomain(subdomain)} {\n`;
// Tailscale-only access restriction
if (tailscaleOnly) {
caddyConfig += ` @blocked not remote_ip 100.64.0.0/10\n`;
caddyConfig += ` respond @blocked "Access denied. Tailscale connection required." 403\n`;
}
// SSL Configuration
switch (sslType) {
case 'letsencrypt':
break;
case 'caddy-managed':
caddyConfig += ` tls internal\n`;
break;
case 'existing-ca':
if (existingCa) {
caddyConfig += ` tls {\n ca ${existingCa}\n }\n`;
}
break;
case 'custom-ca':
if (caName) {
caddyConfig += ` tls {\n ca ${caName}\n }\n`;
}
break;
}
// Authentication
if (enableAuth) {
caddyConfig += ` basicauth {\n admin $2a$14$hashed_password_here\n }\n`;
}
// CORS Headers
if (enableCors) {
caddyConfig += ` header {\n`;
caddyConfig += ` Access-Control-Allow-Origin "*"\n`;
caddyConfig += ` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"\n`;
caddyConfig += ` Access-Control-Allow-Headers "Content-Type, Authorization"\n`;
caddyConfig += ` }\n`;
}
// Custom Headers
if (customHeaders) {
try {
const headers = JSON.parse(customHeaders);
caddyConfig += ` header {\n`;
Object.entries(headers).forEach(([key, value]) => {
caddyConfig += ` ${key} "${value}"\n`;
});
caddyConfig += ` }\n`;
} catch (e) {
console.warn('Invalid JSON in custom headers');
}
}
// Health Check
if (healthCheck) {
caddyConfig += ` health_uri ${healthCheck}\n`;
}
// Reverse Proxy
caddyConfig += ` reverse_proxy ${ip}:${port} {\n`;
if (upstreamPath && upstreamPath !== '/') {
caddyConfig += ` rewrite ${upstreamPath}\n`;
}
if (timeout && timeout !== 30) {
caddyConfig += ` transport http {\n`;
caddyConfig += ` dial_timeout ${timeout}s\n`;
caddyConfig += ` response_header_timeout ${timeout}s\n`;
caddyConfig += ` }\n`;
}
caddyConfig += ` }\n`;
caddyConfig += `}\n`;
return caddyConfig;
}
// ===== CREATE DNS RECORD =====
async function createDnsRecord(subdomain, ip, ttl = DC.DEFAULTS.TTL) {
const dnsToken = window.getToken('dns2', 'admin');
if (!dnsToken) {
throw new Error('DNS admin token not configured. Please set it in the Tokens menu.');
}
const domain = buildDomain(subdomain);
const response = await secureFetch('/api/v1/dns/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: domain,
ip: ip,
ttl: ttl,
token: dnsToken,
server: SITE.dnsIp
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`DNS API Error: ${response.status} - ${errorText}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(`DNS Error: ${result.error || 'Unknown error'}`);
}
return result;
}
// ===== ADD SERVICE TO CONFIG =====
async function addServiceToConfig(serviceConfig) {
const newService = {
id: serviceConfig.subdomain,
name: serviceConfig.name,
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
};
try {
const response = await secureFetch('/api/v1/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newService)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to save service');
}
await window.loadServices();
window.buildGrid();
return newService;
} catch (error) {
console.error('Failed to add service to config:', error);
throw error;
}
}
// ===== ADD TO CADDYFILE =====
async function addToCaddyfile(config) {
const subdomain = document.getElementById('service-subdomain-input').value.trim();
const ip = document.getElementById('service-ip-input').value.trim() || 'localhost';
const port = document.getElementById('service-port-input').value.trim() || '80';
const response = await secureFetch('/api/v1/site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: buildDomain(subdomain),
upstream: `${ip}:${port}`,
config: config
})
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || `Caddy API Error: ${response.status}`);
}
return result;
}
// ===== WINDOW EXPORTS =====
window.loadExistingCAs = loadExistingCAs;
window.generateCaddyConfig = generateCaddyConfig;
window.createDnsRecord = createDnsRecord;
window.addServiceToConfig = addServiceToConfig;
window.addToCaddyfile = addToCaddyfile;
})();

View File

@@ -0,0 +1,334 @@
// ========== SERVICE MODAL TEMPLATES ==========
// Injects the HTML for service edit, delete, and add modals into the DOM.
// Must load before service-crud.js and service-create.js.
(function () {
injectModal('service-edit-modal', `
<div id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3>
<div style="display: grid; gap: 16px; margin-top: 16px;">
<!-- Service Info -->
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--card-bg); border-radius: 8px;">
<img id="edit-service-logo-preview" src="" alt="" style="width: 48px; height: 48px; border-radius: 8px; object-fit: contain; background: var(--bg);" />
<div>
<div id="edit-service-name-display" style="font-weight: 600; font-size: 1.1rem;"></div>
<div id="edit-service-url-display" class="text-muted-sm"></div>
</div>
</div>
<!-- Subdomain -->
<div>
<label for="edit-subdomain" class="form-label-accent-sm">
Subdomain
</label>
<div class="flex-row-gap-center">
<input type="text" id="edit-subdomain" class="input-flex" />
<span id="edit-tld-suffix" style="color: var(--muted);">.home</span>
</div>
</div>
<!-- Port -->
<div>
<label for="edit-port" class="form-label-accent-sm">
Port
</label>
<input type="number" id="edit-port" class="form-input-md" />
<div class="form-hint-sm">
The port Caddy will proxy to (container's exposed port)
</div>
</div>
<!-- IP Address -->
<div>
<label for="edit-ip" class="form-label-accent-sm">
IP Address
</label>
<input type="text" id="edit-ip" class="form-input-md" />
</div>
<!-- Tailscale Protection -->
<div>
<label style="display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" id="edit-tailscale-only" style="width: 18px; height: 18px;" />
<div>
<div class="fw-500">Tailscale-Only Access</div>
<div class="text-hint">Restrict this service to Tailscale users only</div>
</div>
</label>
</div>
<!-- Logo -->
<div>
<label class="form-label-accent-sm">
Service Logo
</label>
<div class="flex-row-gap-center">
<input type="text" id="edit-logo-url" placeholder="/assets/service.png or https://..." class="input-flex" />
<label style="padding: 10px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; white-space: nowrap;">
<input type="file" id="edit-logo-file" accept="image/*" style="display: none;" />
Upload
</label>
</div>
<div class="form-hint-sm">
Enter a URL or upload an image file (PNG, JPG, SVG)
</div>
</div>
</div>
<div class="weather-modal-buttons" style="margin-top: 24px;">
<button id="service-edit-cancel">Cancel</button>
<button id="service-edit-save" class="btn-accent">
Save Changes
</button>
</div>
</div>
</div>`);
injectModal('delete-service-modal', `
<div id="delete-service-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 400px; max-width: 500px;">
<h3 id="delete-modal-title" class="mb-16">Delete Service</h3>
<div id="delete-modal-message" style="margin-bottom: 20px; line-height: 1.5;">
<!-- Dynamic content -->
</div>
<div id="delete-modal-container-info" style="display: none; margin-bottom: 20px; padding: 12px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 500; margin-bottom: 8px;">Docker Container</div>
<div id="delete-modal-container-name" style="font-size: 0.9rem; color: var(--muted);"></div>
</div>
<div class="weather-modal-buttons" style="display: flex; gap: 8px; justify-content: flex-end;">
<button id="delete-modal-cancel" style="padding: 10px 20px;">Cancel</button>
<button id="delete-modal-remove" style="padding: 10px 20px; background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">
Remove
</button>
<button id="delete-modal-delete" style="display: none; padding: 10px 20px; background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">
Delete
</button>
</div>
<div id="delete-modal-help" style="display: none; margin-top: 16px; padding: 12px; background: color-mix(in srgb, var(--muted) 10%, transparent); border-radius: 8px; font-size: 0.85rem; color: var(--muted);">
<div><strong>Remove:</strong> Remove from dashboard only (container keeps running)</div>
<div style="margin-top: 6px;"><strong>Delete:</strong> Full removal - stops container, removes DNS & Caddy config</div>
</div>
</div>
</div>`);
injectModal('add-service-modal', `
<div id="add-service-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>Add Service</h3>
<!-- Service Type Tabs -->
<div style="display: flex; gap: 2px; margin-bottom: 16px; background: var(--card-bg); border-radius: 8px; padding: 3px; border: 1px solid var(--border);">
<label id="tab-local" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; background: var(--accent); color: var(--bg); transition: all 0.15s;">
<input type="radio" name="service-type" value="local" id="service-type-local" checked style="display: none;" />
Local
</label>
<label id="tab-external" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; color: var(--muted); transition: all 0.15s;">
<input type="radio" name="service-type" value="external" id="service-type-external" style="display: none;" />
External
</label>
</div>
<span id="service-type-description" style="display: none;"></span>
<!-- LOCAL SERVICE -->
<div id="local-service-config" style="display: grid; gap: 12px;">
<div>
<label for="service-name-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
<input type="text" id="service-name-input" placeholder="e.g., Jellyfin" style="font-size: 1rem;" />
<div id="subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
</div>
<div class="grid-2col">
<div>
<label for="service-port-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Port</label>
<input type="number" id="service-port-input" placeholder="e.g., 8096" style="font-size: 1rem;" />
</div>
<div>
<label for="service-ip-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">IP Address</label>
<input type="text" id="service-ip-input" placeholder="Auto-detected" style="font-size: 1rem;" />
<div class="quick-ip-buttons" style="display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;">
<button type="button" class="quick-ip-btn" data-ip="127.0.0.1" title="Localhost" style="font-size: 0.7rem; padding: 2px 6px;">localhost</button>
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-lan" title="LAN IP" style="font-size: 0.7rem; padding: 2px 6px;">LAN</button>
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-tailscale" title="Tailscale IP" style="font-size: 0.7rem; padding: 2px 6px;">Tailscale</button>
</div>
</div>
</div>
<!-- Options (collapsed by default) -->
<details id="local-advanced-options">
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
<div class="grid-2col">
<div>
<label for="service-subdomain-input">Subdomain:</label>
<input type="text" id="service-subdomain-input" placeholder="auto-derived from name" />
</div>
<div>
<label for="service-logo-input">Logo URL:</label>
<input type="text" id="service-logo-input" placeholder="/assets/name.png" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: start;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="create-dns-record" checked />
Create DNS Record
</label>
<div>
<label for="ssl-type-select">SSL:</label>
<select id="ssl-type-select" style="width: 100%;">
<option value="caddy-managed">Caddy Managed (Internal)</option>
<option value="letsencrypt">Let's Encrypt</option>
<option value="existing-ca">Existing CA</option>
<option value="custom-ca">Custom CA</option>
</select>
</div>
</div>
<div id="dns-config">
<div class="grid-2col">
<div>
<label for="dns-ttl-input">DNS TTL:</label>
<input type="number" id="dns-ttl-input" value="300" />
</div>
<div>
<label for="caddyfile-path-input">Caddyfile Path:</label>
<input type="text" id="caddyfile-path-input" value="C:\\caddy\\Caddyfile" />
</div>
</div>
</div>
<div id="existing-ca-config" style="display: none;">
<label for="existing-ca-select">Existing CA:</label>
<div class="flex-row-gap">
<select id="existing-ca-select" style="flex: 1;">
<option value="">Loading CAs...</option>
</select>
<button type="button" id="refresh-cas" style="padding: 4px 8px; font-size: 0.75rem;">\u{1F504}</button>
</div>
</div>
<div id="custom-ca-config" style="display: none;">
<label for="ca-name-input">CA Name:</label>
<input type="text" id="ca-name-input" placeholder="e.g., sami-ca" />
</div>
<div id="manual-tailscale-status" style="padding: 6px 10px; background: var(--card-bg); border-radius: 6px; font-size: 0.75rem;">
<span style="color: var(--muted);">Checking Tailscale...</span>
</div>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="manual-tailscale-only" />
Tailscale-Only Access
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="reload-caddy" checked />
Reload Caddy after adding
</label>
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
<div class="grid-2col">
<div>
<label class="field-label-sm">
<input type="checkbox" id="enable-auth" />
Authentication
</label>
<label class="field-label-sm">
<input type="checkbox" id="enable-cors" />
CORS Headers
</label>
<label for="upstream-path-input">Upstream Path:</label>
<input type="text" id="upstream-path-input" value="/" />
</div>
<div>
<label for="health-check-input">Health Check:</label>
<input type="text" id="health-check-input" placeholder="/health" />
<label for="timeout-input">Timeout (s):</label>
<input type="number" id="timeout-input" value="30" />
<label for="custom-headers-input">Headers (JSON):</label>
<textarea id="custom-headers-input" placeholder='{"X-Custom": "value"}' rows="2" style="font-size: 0.7rem;"></textarea>
</div>
</div>
</div>
</details>
</div>
<!-- EXTERNAL SERVICE -->
<div id="external-service-config" style="display: none;">
<div style="display: grid; gap: 12px;">
<div>
<label for="external-service-name" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
<input type="text" id="external-service-name" placeholder="e.g., Radarr (Seedhost)" style="font-size: 1rem;" />
<div id="external-subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
</div>
<div>
<label for="external-service-url" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">External URL</label>
<input type="url" id="external-service-url" placeholder="https://username.seedhost.eu/radarr" style="font-size: 1rem;" />
</div>
<!-- Options (collapsed by default) -->
<details id="external-advanced-options">
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
<div class="grid-2col">
<div>
<label for="external-service-subdomain">Subdomain:</label>
<input type="text" id="external-service-subdomain" placeholder="auto-derived from name" />
<span id="external-domain-preview" style="font-size: 0.7rem; color: var(--accent);"></span>
</div>
<div>
<label for="external-service-logo">Logo URL:</label>
<input type="text" id="external-service-logo" placeholder="/assets/name.png" />
</div>
</div>
<div>
<label for="external-service-icon">Icon Emoji:</label>
<input type="text" id="external-service-icon" placeholder="\u{1F3AC}" maxlength="2" style="width: 60px;" />
</div>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="external-create-dns" checked />
Create DNS Record
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="external-create-caddy" checked />
Create Caddy Reverse Proxy
</label>
<div>
<label for="external-proxy-ip">Proxy Server IP:</label>
<input type="text" id="external-proxy-ip" placeholder="Auto-detected" />
</div>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="external-preserve-host" checked />
Preserve Host Header
</label>
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="external-follow-redirects" checked />
Follow Redirects
</label>
</div>
</details>
</div>
</div>
<div class="weather-modal-buttons" style="margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;">
<button id="add-service-cancel">Cancel</button>
<button id="add-service-create" class="btn-accent">Create Service</button>
</div>
</div>
</div>`);
})();