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:
387
status/js/core/credentials.js
Normal file
387
status/js/core/credentials.js
Normal 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
273
status/js/core/dns.js
Normal 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
384
status/js/core/grid.js
Normal 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
204
status/js/core/init.js
Normal 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
672
status/js/core/logs.js
Normal 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}×tamps=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;
|
||||
|
||||
})();
|
||||
648
status/js/core/service-create.js
Normal file
648
status/js/core/service-create.js
Normal 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;
|
||||
|
||||
})();
|
||||
429
status/js/core/service-crud.js
Normal file
429
status/js/core/service-crud.js
Normal 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;
|
||||
|
||||
})();
|
||||
245
status/js/core/service-infrastructure.js
Normal file
245
status/js/core/service-infrastructure.js
Normal 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;
|
||||
|
||||
})();
|
||||
334
status/js/core/service-modals.js
Normal file
334
status/js/core/service-modals.js
Normal 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>`);
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user