reorg
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
// Backups UI functions
|
||||
|
||||
let backupsData = [];
|
||||
let filteredBackups = [];
|
||||
|
||||
// Fetch backups from API
|
||||
async function fetchBackups() {
|
||||
try {
|
||||
const response = await fetch('/api/backups');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch backups');
|
||||
}
|
||||
backupsData = await response.json();
|
||||
filteredBackups = backupsData;
|
||||
return backupsData;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch backups:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to load backups', 'error');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Render backups list
|
||||
async function renderBackups() {
|
||||
await fetchBackups();
|
||||
filterBackups();
|
||||
}
|
||||
|
||||
// Filter backups
|
||||
function filterBackups() {
|
||||
const searchEl = document.getElementById('search-backups');
|
||||
if (!searchEl) return;
|
||||
const query = searchEl.value.toLowerCase();
|
||||
filteredBackups = backupsData.filter(backup =>
|
||||
backup.name.toLowerCase().includes(query) ||
|
||||
backup.timestamp.toLowerCase().includes(query) ||
|
||||
(backup.version && backup.version.toLowerCase().includes(query))
|
||||
);
|
||||
|
||||
// Reset infinite scroll state when filtering
|
||||
if (!window.infiniteScrollState) {
|
||||
window.infiniteScrollState = {};
|
||||
}
|
||||
if (window.infiniteScrollState.backups) {
|
||||
window.infiniteScrollState.backups.loadedCount = 0;
|
||||
window.infiniteScrollState.backups.lastQuery = query;
|
||||
}
|
||||
|
||||
renderBackupsTable();
|
||||
}
|
||||
|
||||
// Render backups table with infinite scroll
|
||||
function renderBackupsTable() {
|
||||
const container = document.getElementById('backupsTable');
|
||||
if (!container) return;
|
||||
|
||||
// Initialize infinite scroll state
|
||||
if (!window.infiniteScrollState) {
|
||||
window.infiniteScrollState = {};
|
||||
}
|
||||
if (!window.infiniteScrollState.backups) {
|
||||
window.infiniteScrollState.backups = {
|
||||
loadedCount: 0,
|
||||
observer: null,
|
||||
batchSize: 15,
|
||||
lastQuery: ''
|
||||
};
|
||||
}
|
||||
|
||||
const state = window.infiniteScrollState.backups;
|
||||
const searchEl = document.getElementById('search-backups');
|
||||
const query = searchEl ? searchEl.value.toLowerCase() : '';
|
||||
const isNewSearch = state.lastQuery !== query;
|
||||
|
||||
// Reset if new search
|
||||
if (isNewSearch) {
|
||||
state.loadedCount = 0;
|
||||
state.lastQuery = query;
|
||||
container.innerHTML = '';
|
||||
|
||||
// Disconnect existing observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty state
|
||||
if (filteredBackups.length === 0) {
|
||||
container.innerHTML = '<tr><td colspan="6" class="p-4 text-center theme-text-tertiary">No backups found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Load next batch
|
||||
loadBackupsBatch();
|
||||
}
|
||||
|
||||
// Load next batch of backups
|
||||
function loadBackupsBatch() {
|
||||
const container = document.getElementById('backupsTable');
|
||||
if (!container) return;
|
||||
|
||||
const state = window.infiniteScrollState.backups;
|
||||
if (!state) return;
|
||||
|
||||
const start = state.loadedCount;
|
||||
const end = Math.min(start + state.batchSize, filteredBackups.length);
|
||||
const batch = filteredBackups.slice(start, end);
|
||||
|
||||
if (batch.length === 0) {
|
||||
// No more data to load
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
// Remove sentinel if exists
|
||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
if (sentinel) sentinel.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Render batch
|
||||
batch.forEach(backup => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
const date = new Date(backup.timestamp);
|
||||
const dateStr = date.toLocaleString();
|
||||
tr.innerHTML = `
|
||||
<td class="p-3">${backup.name}</td>
|
||||
<td class="p-3">${dateStr}</td>
|
||||
<td class="p-3">${backup.sizeFormatted || '0 B'}</td>
|
||||
<td class="p-3">${backup.files ? backup.files.length : 0}</td>
|
||||
<td class="p-3">${backup.version || 'unknown'}</td>
|
||||
<td class="p-3">
|
||||
<button onclick="viewBackupDetails('${backup.name}')" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 mr-2">Details</button>
|
||||
<button onclick="restoreBackup('${backup.name}')" class="px-2 py-1 bg-green-500 text-white rounded hover:bg-green-600 mr-2">Restore</button>
|
||||
<button onclick="deleteBackup('${backup.name}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
container.appendChild(tr);
|
||||
});
|
||||
|
||||
state.loadedCount = end;
|
||||
|
||||
// Setup IntersectionObserver for next batch
|
||||
if (end < filteredBackups.length) {
|
||||
setupBackupsObserver(container);
|
||||
} else {
|
||||
// All data loaded, disconnect observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
// Remove sentinel if exists
|
||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
if (sentinel) sentinel.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Setup IntersectionObserver for backups
|
||||
function setupBackupsObserver(container) {
|
||||
const state = window.infiniteScrollState.backups;
|
||||
if (!state) return;
|
||||
|
||||
// Create or get sentinel element
|
||||
let sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
if (!sentinel) {
|
||||
sentinel = document.createElement('tr');
|
||||
sentinel.className = 'infinite-scroll-sentinel';
|
||||
sentinel.innerHTML = '<td colspan="6" style="height: 1px; padding: 0;"></td>';
|
||||
container.appendChild(sentinel);
|
||||
}
|
||||
|
||||
// Find the scrollable container (must be a parent with overflow-y-auto or overflow-auto)
|
||||
const scrollContainer = container.closest('.overflow-y-auto, .overflow-auto');
|
||||
|
||||
if (!scrollContainer) {
|
||||
console.warn('No scrollable container found for backups');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect existing observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
}
|
||||
|
||||
state.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
loadBackupsBatch();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: scrollContainer,
|
||||
rootMargin: '200px'
|
||||
});
|
||||
|
||||
state.observer.observe(sentinel);
|
||||
}
|
||||
|
||||
// Pagination function removed - using infinite scroll instead
|
||||
|
||||
// Create backup
|
||||
async function createBackup() {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Create a new backup? This may take a moment.', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/backups/create', {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to create backup');
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Backup created successfully');
|
||||
await renderBackups();
|
||||
} catch (err) {
|
||||
console.error('Failed to create backup:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to create backup: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Restore backup
|
||||
async function restoreBackup(backupName) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Restore from backup "${backupName}"? This will create a backup of current state first, then restore.`, async () => {
|
||||
try {
|
||||
const response = await fetch('/api/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backupName })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to restore backup');
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Backup restored successfully. System may need to refresh.', 'success');
|
||||
await renderBackups();
|
||||
// Optionally reload after restore
|
||||
setTimeout(() => {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Reload page to see restored data?', () => {
|
||||
location.reload();
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to restore backup:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to restore backup: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete backup
|
||||
async function deleteBackup(backupName) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Delete backup "${backupName}"? This action cannot be undone.`, async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/backups/${encodeURIComponent(backupName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete backup');
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Backup deleted successfully');
|
||||
|
||||
// Re-fetch and re-render backups
|
||||
await renderBackups();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete backup:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to delete backup: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// View backup details
|
||||
async function viewBackupDetails(backupName) {
|
||||
try {
|
||||
const response = await fetch(`/api/backups/${encodeURIComponent(backupName)}/metadata`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch backup details');
|
||||
}
|
||||
const metadata = await response.json();
|
||||
|
||||
const modal = document.getElementById('backupDetailsModal');
|
||||
if (!modal) return;
|
||||
|
||||
const content = document.getElementById('backup-details-content');
|
||||
if (content) {
|
||||
const filesList = metadata.files.map(file =>
|
||||
`<div class="mb-2 p-2 bg-gray-100 dark:bg-gray-700 rounded">
|
||||
<div class="font-semibold">${file.name}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Size: ${file.sizeFormatted} | Modified: ${file.modified ? new Date(file.modified).toLocaleString() : 'N/A'}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="mb-4">
|
||||
<h4 class="font-semibold mb-2">Backup Information</h4>
|
||||
<p><strong>Name:</strong> ${metadata.name || backupName}</p>
|
||||
<p><strong>Timestamp:</strong> ${new Date(metadata.timestamp).toLocaleString()}</p>
|
||||
<p><strong>Version:</strong> ${metadata.version || 'unknown'}</p>
|
||||
<p><strong>Files:</strong> ${metadata.files ? metadata.files.length : 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">Files in Backup</h4>
|
||||
${filesList || '<p class="text-gray-500">No files found</p>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
modal.showModal();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch backup details:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to load backup details: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.renderBackups = renderBackups;
|
||||
window.createBackup = createBackup;
|
||||
window.restoreBackup = restoreBackup;
|
||||
window.deleteBackup = deleteBackup;
|
||||
window.viewBackupDetails = viewBackupDetails;
|
||||
window.filterBackups = filterBackups;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Certificates UI functions
|
||||
function regenerateCA() {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Regenerate Root CA?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/regenerate-ca', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Root CA regenerated successfully');
|
||||
if (window.genericFetch) window.genericFetch('certs', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to regenerate CA:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to regenerate CA: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function installCA() {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Install Root CA?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/install-ca', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Root CA installed successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to install CA:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to install CA: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCert() {
|
||||
const domainEl = document.getElementById('cert-domain');
|
||||
if (!domainEl) return;
|
||||
const domain = domainEl.value;
|
||||
try {
|
||||
const response = await fetch('/api/generate-cert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Certificate generated successfully');
|
||||
if (window.genericFetch) window.genericFetch('certs', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to generate cert:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to generate cert: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCert(domain) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Delete certificate for ${domain}?`, async () => {
|
||||
try {
|
||||
const response = await fetch('/api/delete-cert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Certificate deleted successfully');
|
||||
if (window.genericFetch) window.genericFetch('certs', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete cert:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to delete cert: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function regenerateCert(domain) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Regenerate certificate for ${domain}?`, async () => {
|
||||
try {
|
||||
const response = await fetch('/api/regenerate-cert', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Certificate regenerated successfully');
|
||||
if (window.genericFetch) window.genericFetch('certs', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to regenerate cert:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to regenerate cert: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function showCertDetails(domain) {
|
||||
try {
|
||||
const res = await fetch(`/api/cert-details?domain=${encodeURIComponent(domain)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
const data = await res.text();
|
||||
const formatted = formatCertificate(data);
|
||||
const contentEl = document.getElementById('cert-details-content');
|
||||
const modal = document.getElementById('certDetailsModal');
|
||||
if (contentEl) contentEl.textContent = formatted;
|
||||
if (modal) modal.showModal();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cert details:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to load certificate details: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatCertificate(certPem) {
|
||||
return certPem.replace(/(-----BEGIN CERTIFICATE-----)/g, '\n$1\n')
|
||||
.replace(/(-----END CERTIFICATE-----)/g, '\n$1\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function copyCertDetails() {
|
||||
const contentEl = document.getElementById('cert-details-content');
|
||||
if (!contentEl) return;
|
||||
const content = contentEl.textContent;
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
if (window.showNotification) window.showNotification('Certificate details copied to clipboard', 'success');
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to copy certificate details', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
window.regenerateCA = regenerateCA;
|
||||
window.installCA = installCA;
|
||||
window.generateCert = generateCert;
|
||||
window.deleteCert = deleteCert;
|
||||
window.regenerateCert = regenerateCert;
|
||||
window.showCertDetails = showCertDetails;
|
||||
window.formatCertificate = formatCertificate;
|
||||
window.copyCertDetails = copyCertDetails;
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// Configuration constants
|
||||
window.updateMap = {
|
||||
'update-database': ['domains', 'entries'],
|
||||
'update-peers': ['peers'],
|
||||
'update-certs': ['certs'],
|
||||
'update-interfaces': ['interfaces'],
|
||||
'update-local-dns': ['local-dns', 'dns-conflicts'],
|
||||
'update-holesail': [],
|
||||
'update-holesail-clients': [],
|
||||
'update-settings': ['settings'],
|
||||
'update-stats': [],
|
||||
'system-reset': () => location.reload()
|
||||
};
|
||||
|
||||
window.paginationState = {
|
||||
domains: { current: 1, size: 8 },
|
||||
entries: { current: 1, size: 10 },
|
||||
peers: { current: 1, size: 4 },
|
||||
certs: { current: 1, size: 5 },
|
||||
interfaces: { current: 1, size: 10 },
|
||||
'local-dns': { current: 1, size: 10 },
|
||||
'dns-conflicts': { current: 1, size: 10 },
|
||||
'host-servers': { current: 1, size: 10 },
|
||||
'host-clients': { current: 1, size: 10 },
|
||||
settings: { current: 1, size: 20 },
|
||||
backups: { current: 1, size: 10 },
|
||||
plugins: { current: 1, size: 10 }
|
||||
};
|
||||
|
||||
// Helper function to get consensus status badge
|
||||
function getConsensusStatusBadge(status) {
|
||||
const badges = {
|
||||
'resolved': '<span class="px-2 py-1 bg-green-500 text-white rounded text-xs">Resolved</span>',
|
||||
'tie': '<span class="px-2 py-1 bg-yellow-500 text-white rounded text-xs">Tie</span>',
|
||||
'insufficient_quorum': '<span class="px-2 py-1 bg-orange-500 text-white rounded text-xs">No Quorum</span>',
|
||||
'no_claims': '<span class="px-2 py-1 bg-gray-500 text-white rounded text-xs">No Claims</span>',
|
||||
'error': '<span class="px-2 py-1 bg-red-500 text-white rounded text-xs">Error</span>',
|
||||
'internal': '<span class="px-2 py-1 bg-blue-500 text-white rounded text-xs">Internal</span>',
|
||||
'unknown': '<span class="px-2 py-1 bg-gray-400 text-white rounded text-xs">Unknown</span>'
|
||||
};
|
||||
return badges[status] || badges['unknown'];
|
||||
}
|
||||
|
||||
// Make function globally available
|
||||
window.getConsensusStatusBadge = getConsensusStatusBadge;
|
||||
|
||||
window.chartColors = {
|
||||
primary: 'rgb(59, 130, 246)',
|
||||
success: 'rgb(34, 197, 94)',
|
||||
warning: 'rgb(234, 179, 8)',
|
||||
danger: 'rgb(239, 68, 68)',
|
||||
info: 'rgb(59, 130, 246)',
|
||||
gray: 'rgb(107, 114, 128)',
|
||||
dark: 'rgb(17, 24, 39)'
|
||||
};
|
||||
|
||||
window.darkModeColors = {
|
||||
primary: 'rgb(96, 165, 250)',
|
||||
success: 'rgb(74, 222, 128)',
|
||||
warning: 'rgb(250, 204, 21)',
|
||||
danger: 'rgb(248, 113, 113)',
|
||||
info: 'rgb(96, 165, 250)',
|
||||
gray: 'rgb(156, 163, 175)',
|
||||
dark: 'rgb(243, 244, 246)'
|
||||
};
|
||||
|
||||
// Tabs configuration - uses functions from utils.js and other modules
|
||||
window.tabs = {
|
||||
domains: {
|
||||
api: '/api/resolved-domains',
|
||||
searchId: 'search-domains',
|
||||
dataKey: 'domainsData',
|
||||
filteredKey: 'filteredDomains',
|
||||
containerId: 'domainsTable',
|
||||
paginationId: 'domainsPagination',
|
||||
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.hash.toLowerCase().includes(query) || (item.consensusStatus || '').toLowerCase().includes(query),
|
||||
renderItem: (item) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
|
||||
// Use consensus state from postFetch if available
|
||||
let consensusInfo = '';
|
||||
if (item.consensusState) {
|
||||
const consensusState = item.consensusState;
|
||||
const statusBadge = getConsensusStatusBadge(consensusState.status);
|
||||
const voteInfo = Object.keys(consensusState.voteCounts || {}).length > 0
|
||||
? ` (${Object.values(consensusState.voteCounts).reduce((a, b) => a + b, 0)} votes)`
|
||||
: '';
|
||||
const quorumInfo = consensusState.quorumMet ? '✓' : '✗';
|
||||
consensusInfo = `<td class="p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>${statusBadge}</span>
|
||||
<span class="text-xs" style="color: var(--text-primary);">${voteInfo}</span>
|
||||
<span class="text-xs" style="color: var(--text-primary);" title="Quorum: ${consensusState.quorumMet ? 'Met' : 'Not Met'}">${quorumInfo}</span>
|
||||
</div>
|
||||
</td>`;
|
||||
} else if (item.consensusStatus === 'internal') {
|
||||
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('internal')}</td>`;
|
||||
} else {
|
||||
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('unknown')}</td>`;
|
||||
}
|
||||
|
||||
tr.innerHTML = `<td class="p-3">${item.domain}${item.isLocal ? '🏠' : ''}</td>
|
||||
<td class="p-3 break-all">${item.hash}</td>
|
||||
${consensusInfo}
|
||||
<td class="p-3">${item.isLocal && item.hash !== 'internal' ? `<button onclick="removeDomain('${item.domain}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Remove</button>` : ''}</td>`;
|
||||
return tr;
|
||||
},
|
||||
postFetch: async (data) => {
|
||||
// Fetch consensus states for all domains
|
||||
const domainsWithConsensus = await Promise.all(data.map(async (item) => {
|
||||
if (item.hash === 'internal' || item.hash === 'none') {
|
||||
return { ...item, consensusStatus: 'internal' };
|
||||
}
|
||||
try {
|
||||
const consensusRes = await fetch(`/api/consensus/${encodeURIComponent(item.domain)}`);
|
||||
if (consensusRes.ok) {
|
||||
const consensusState = await consensusRes.json();
|
||||
return { ...item, consensusStatus: consensusState.status, consensusState };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch consensus for ${item.domain}:`, err);
|
||||
}
|
||||
return { ...item, consensusStatus: 'unknown' };
|
||||
}));
|
||||
return domainsWithConsensus;
|
||||
}
|
||||
},
|
||||
entries: {
|
||||
api: '/api/entries',
|
||||
searchId: 'search-entries',
|
||||
dataKey: 'entriesData',
|
||||
filteredKey: 'filteredEntries',
|
||||
containerId: 'entriesTable',
|
||||
paginationId: 'entriesPagination',
|
||||
sentinelId: 'entriesScrollSentinel',
|
||||
countId: 'entries-count',
|
||||
useLazyScroll: true,
|
||||
sort: (a, b) => a.key.localeCompare(b.key, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.key.toLowerCase().includes(query) || item.value.toLowerCase().includes(query),
|
||||
renderItem: (item) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `<td class="p-3 break-all">${item.key}</td>
|
||||
<td class="p-3 break-all">${item.value}</td>`;
|
||||
return tr;
|
||||
}
|
||||
},
|
||||
peers: {
|
||||
api: '/api/peers',
|
||||
searchId: 'search-peers',
|
||||
dataKey: 'peersData',
|
||||
filteredKey: 'filteredPeers',
|
||||
containerId: 'peersList',
|
||||
paginationId: 'peersPagination',
|
||||
sort: (a, b) => (a.id || '').localeCompare(b.id || '', undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => {
|
||||
const queryLower = query.toLowerCase();
|
||||
return (item.id || '').toLowerCase().includes(queryLower) ||
|
||||
(item.connected ? 'connected' : 'disconnected').includes(queryLower);
|
||||
},
|
||||
renderItem: (peer) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow';
|
||||
|
||||
const uptime = peer.uptime ? (window.formatUptime ? window.formatUptime(peer.uptime) : `${Math.floor(peer.uptime / 1000)}s`) : 'N/A';
|
||||
const statusBadge = peer.connected
|
||||
? '<span class="px-2 py-1 bg-green-500 rounded text-sm" style="color: var(--text-primary);">Connected</span>'
|
||||
: '<span class="px-2 py-1 bg-gray-500 rounded text-sm" style="color: var(--text-primary);">Disconnected</span>';
|
||||
const blockedBadge = peer.isBlocked
|
||||
? '<span class="px-2 py-1 bg-red-500 rounded text-sm ml-2" style="color: var(--text-primary);">Blocked</span>'
|
||||
: '';
|
||||
|
||||
li.innerHTML = `
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="font-mono text-sm break-all cursor-pointer text-blue-500 hover:underline" onclick="showPeerDetails('${peer.id}')">${peer.id}</span>
|
||||
${statusBadge}
|
||||
${blockedBadge}
|
||||
</div>
|
||||
<div class="text-sm theme-text-secondary">
|
||||
<div>Uptime: ${uptime}</div>
|
||||
<div>Connections: ${peer.metrics?.connections || 0} | Avg Duration: ${peer.metrics?.avgDuration ? window.formatDuration ? window.formatDuration(peer.metrics.avgDuration) : `${Math.floor(peer.metrics.avgDuration / 1000)}s` : 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4 flex-shrink-0">
|
||||
<button onclick="showPeerDetails('${peer.id}')" class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm whitespace-nowrap">Details</button>
|
||||
${peer.isBlocked
|
||||
? `<button onclick="unblockPeer('${peer.id}')" class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 text-sm whitespace-nowrap">Unblock</button>`
|
||||
: `<button onclick="blockPeer('${peer.id}')" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 text-sm whitespace-nowrap">Block</button>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return li;
|
||||
},
|
||||
preRender: (total) => {
|
||||
const el = document.getElementById('peers-count');
|
||||
if (el) el.textContent = `(${total})`;
|
||||
}
|
||||
},
|
||||
certs: {
|
||||
api: '/api/certs',
|
||||
searchId: 'search-certs',
|
||||
dataKey: 'certsData',
|
||||
filteredKey: 'filteredCerts',
|
||||
containerId: 'certsList',
|
||||
paginationId: 'certsPagination',
|
||||
sort: (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.toLowerCase().includes(query),
|
||||
renderItem: (cert) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow flex justify-between items-center';
|
||||
li.innerHTML = `<span class="cursor-pointer flex-1 break-all" onclick="showCertDetails('${cert}')">${cert}</span>
|
||||
<div>
|
||||
<button onclick="deleteCert('${cert}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 mr-2">Delete</button>
|
||||
<button onclick="regenerateCert('${cert}')" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">Regenerate</button>
|
||||
</div>`;
|
||||
return li;
|
||||
}
|
||||
},
|
||||
interfaces: {
|
||||
api: '/api/interfaces',
|
||||
searchId: 'search-interfaces',
|
||||
dataKey: 'interfacesData',
|
||||
filteredKey: 'filteredInterfaces',
|
||||
containerId: 'interfacesTable',
|
||||
paginationId: 'interfacesPagination',
|
||||
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.ip.toLowerCase().includes(query),
|
||||
renderItem: (item) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `<td class="p-3">${item.domain}</td>
|
||||
<td class="p-3">${item.ip}</td>`;
|
||||
return tr;
|
||||
}
|
||||
},
|
||||
'local-dns': {
|
||||
api: '/api/local-dns',
|
||||
searchId: 'search-local-dns',
|
||||
dataKey: 'localDnsData',
|
||||
filteredKey: 'filteredLocalDns',
|
||||
containerId: 'localDnsTable',
|
||||
paginationId: 'localDnsPagination',
|
||||
sort: (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => {
|
||||
const queryLower = query.toLowerCase();
|
||||
return (
|
||||
item.name.toLowerCase().includes(queryLower) ||
|
||||
item.type.toLowerCase().includes(queryLower) ||
|
||||
Object.values(item).some(val => typeof val === 'string' && val.toLowerCase().includes(queryLower))
|
||||
);
|
||||
},
|
||||
renderItem: (item) => {
|
||||
let valueStr = '';
|
||||
if (item.type === 'MX') {
|
||||
valueStr = `${item.preference || ''} ${item.exchange || ''}`.trim();
|
||||
} else if (item.type === 'SRV') {
|
||||
valueStr = `${item.priority || ''} ${item.weight || ''} ${item.port || ''} ${item.target || ''}`.trim();
|
||||
} else if (item.type === 'SOA') {
|
||||
valueStr = `${item.mname || ''} ${item.rname || ''} ${item.serial || ''} ${item.refresh || ''} ${item.retry || ''} ${item.expire || ''} ${item.minimum || ''}`.trim();
|
||||
} else if (item.type === 'CAA') {
|
||||
valueStr = `${item.flags || ''} ${item.tag || ''} ${item.value || ''}`.trim();
|
||||
} else {
|
||||
valueStr = item.data || item.value || '';
|
||||
}
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `
|
||||
<td class="p-3">${item.name}</td>
|
||||
<td class="p-3">${item.type}</td>
|
||||
<td class="p-3 break-all">${valueStr}</td>
|
||||
<td class="p-3">${item.ttl}</td>
|
||||
<td class="p-3">
|
||||
<button onclick="editLocalDns(${item.index})" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 mr-2">Edit</button>
|
||||
<button onclick="deleteLocalDns(${item.index})" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>
|
||||
</td>`;
|
||||
return tr;
|
||||
},
|
||||
postFetch: (data) => {
|
||||
window.dnsConflictsData = data.conflicts || [];
|
||||
window.filteredDnsConflicts = window.dnsConflictsData;
|
||||
data.records = data.records.map((rec, index) => ({ ...rec, index }));
|
||||
if (window.renderDnsConflicts) window.renderDnsConflicts();
|
||||
return data.records;
|
||||
}
|
||||
},
|
||||
'dns-conflicts': {
|
||||
api: '/api/local-dns',
|
||||
searchId: 'search-dns-conflicts',
|
||||
dataKey: 'dnsConflictsData',
|
||||
filteredKey: 'filteredDnsConflicts',
|
||||
containerId: 'dnsConflictsTable',
|
||||
paginationId: 'dnsConflictsPagination',
|
||||
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.version.toLowerCase().includes(query) || item.publicIP.toLowerCase().includes(query),
|
||||
renderItem: (item) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `
|
||||
<td class="p-3">${item.domain}</td>
|
||||
<td class="p-3">${item.publicIP}</td>
|
||||
<td class="p-3">
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<span class="mr-2">${item.version === 'public' ? 'Public' : 'P2P'}</span>
|
||||
<input type="checkbox" ${item.version === 'public' ? 'checked' : ''} onchange="toggleVersionPreference('${item.domain}', this.checked)" class="sr-only peer">
|
||||
<div class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary rounded-full peer peer-checked:bg-primary">
|
||||
<div class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5"></div>
|
||||
</div>
|
||||
</label>
|
||||
</td>`;
|
||||
return tr;
|
||||
},
|
||||
postFetch: (data) => {
|
||||
window.localDnsData = data.records.map((rec, index) => ({ ...rec, index }));
|
||||
window.filteredLocalDns = window.localDnsData;
|
||||
return data.conflicts || [];
|
||||
}
|
||||
},
|
||||
'host-servers': {
|
||||
api: '/api/holesail-servers',
|
||||
searchId: 'search-holesail',
|
||||
dataKey: 'holesailServersData',
|
||||
filteredKey: 'filteredHolesailServers',
|
||||
containerId: 'holesailTable',
|
||||
paginationId: 'holesailPagination',
|
||||
sort: (a, b) => (a.opts.name || a.id).localeCompare(b.opts.name || b.id, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => (item.opts.name || '').toLowerCase().includes(query) || item.id.toLowerCase().includes(query) || item.opts.port.toString().includes(query) || (item.info.url || '').toLowerCase().includes(query),
|
||||
renderItem: (item) => {
|
||||
const isPendingRestart = window.pendingServerRestarts && window.pendingServerRestarts.has(item.id);
|
||||
const isPendingDelete = window.pendingServerDeletions && window.pendingServerDeletions.has(item.id);
|
||||
const restartBtn = isPendingRestart
|
||||
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||
: `<button onclick="restartHolesailServer('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
|
||||
const deleteBtn = isPendingDelete
|
||||
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||
: `<button onclick="deleteHolesailServer('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
|
||||
const protocol = item.opts.udp ? 'UDP' : 'TCP';
|
||||
const url = item.info.url || 'N/A';
|
||||
const truncatedUrl = window.truncateUrl ? window.truncateUrl(url, 40) : url.length > 40 ? url.substring(0, 37) + '...' : url;
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `
|
||||
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.name || item.id}')" title="${item.opts.name || item.id}">${item.opts.name || item.id}</td>
|
||||
<td class="p-3">${item.opts.port}</td>
|
||||
<td class="p-3">${item.opts.host || '0.0.0.0'}</td>
|
||||
<td class="p-3" title="${url}">${truncatedUrl}</td>
|
||||
<td class="p-3">${protocol}</td>
|
||||
<td class="p-3">${window.renderStatusBadge ? window.renderStatusBadge(item.info.state) : item.info.state}</td>
|
||||
<td class="p-3">
|
||||
${restartBtn}
|
||||
${deleteBtn}
|
||||
</td>`;
|
||||
return tr;
|
||||
}
|
||||
},
|
||||
'host-clients': {
|
||||
api: '/api/holesail-clients',
|
||||
searchId: 'search-holesail-clients',
|
||||
dataKey: 'holesailClientsData',
|
||||
filteredKey: 'filteredHolesailClients',
|
||||
containerId: 'holesailClientsTable',
|
||||
paginationId: 'holesailClientsPagination',
|
||||
sort: (a, b) => a.opts.domain.localeCompare(b.opts.domain, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.opts.domain.toLowerCase().includes(query) || item.opts.key.toLowerCase().includes(query) || item.opts.port.toString().includes(query),
|
||||
renderItem: (item) => {
|
||||
const isPendingRestart = window.pendingClientRestarts && window.pendingClientRestarts.has(item.id);
|
||||
const isPendingDelete = window.pendingClientDeletions && window.pendingClientDeletions.has(item.id);
|
||||
const restartBtn = isPendingRestart
|
||||
? `<button disabled class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restarting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||
: `<button onclick="restartHolesailClient('${item.id}')" class="px-2 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 mr-2">Restart</button>`;
|
||||
const deleteBtn = isPendingDelete
|
||||
? `<button disabled class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Deleting... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span></button>`
|
||||
: `<button onclick="deleteHolesailClient('${item.id}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600">Delete</button>`;
|
||||
const protocol = (item.opts.protocol || 'tcp').toUpperCase();
|
||||
const key = item.opts.key || '';
|
||||
const truncatedKey = window.truncateUrl ? window.truncateUrl(key, 30) : key.length > 30 ? key.substring(0, 27) + '...' : key;
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
||||
tr.innerHTML = `
|
||||
<td class="p-3 cursor-pointer text-blue-500 hover:underline" onclick="openHolesailLog('${item.id}', '${item.opts.domain}:${item.opts.port}')" title="${item.opts.domain}">${item.opts.domain}</td>
|
||||
<td class="p-3" title="${key}">${truncatedKey}</td>
|
||||
<td class="p-3">${item.opts.port}</td>
|
||||
<td class="p-3">${protocol}</td>
|
||||
<td class="p-3">${window.renderStatusBadge ? window.renderStatusBadge(item.info.state) : item.info.state}</td>
|
||||
<td class="p-3">
|
||||
${restartBtn}
|
||||
${deleteBtn}
|
||||
</td>`;
|
||||
return tr;
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
api: '/api/settings',
|
||||
searchId: 'search-settings',
|
||||
dataKey: 'settingsData',
|
||||
filteredKey: 'filteredSettings',
|
||||
containerId: 'settingsContainer',
|
||||
paginationId: 'settingsPagination',
|
||||
sort: null,
|
||||
filter: null,
|
||||
renderItem: null,
|
||||
postFetch: (data) => {
|
||||
// Store metadata globally for renderSettings to use
|
||||
window.settingsMetadata = data.metadata || {};
|
||||
// Return the full data object so metadata is preserved
|
||||
return data;
|
||||
}
|
||||
},
|
||||
backups: {
|
||||
api: '/api/backups',
|
||||
searchId: 'search-backups',
|
||||
dataKey: 'backupsData',
|
||||
filteredKey: 'filteredBackups',
|
||||
containerId: 'backupsTable',
|
||||
paginationId: 'backupsPagination',
|
||||
sort: (a, b) => new Date(b.timestamp) - new Date(a.timestamp),
|
||||
filter: (item, query) => item.name.toLowerCase().includes(query) || item.timestamp.toLowerCase().includes(query),
|
||||
renderItem: null // Custom renderer in backups.js
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,396 @@
|
||||
// Highly customizable confirmation modal component
|
||||
// Supports custom titles, messages, buttons, icons, types, and more
|
||||
|
||||
const ConfirmationModal = {
|
||||
// Default configuration
|
||||
defaults: {
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to proceed?',
|
||||
type: 'default', // 'default', 'warning', 'danger', 'info', 'success'
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
confirmButtonClass: '',
|
||||
cancelButtonClass: '',
|
||||
showCancel: true,
|
||||
allowHTML: false,
|
||||
icon: null, // Custom icon HTML or null for default icons
|
||||
onConfirm: null,
|
||||
onCancel: null,
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true,
|
||||
focusConfirm: true,
|
||||
width: 'max-w-md', // Tailwind width class
|
||||
zIndex: 'z-50'
|
||||
},
|
||||
|
||||
// Type-specific configurations
|
||||
typeConfigs: {
|
||||
warning: {
|
||||
title: 'Warning',
|
||||
icon: `<svg class="w-6 h-6 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>`,
|
||||
confirmButtonClass: 'bg-yellow-glass'
|
||||
},
|
||||
danger: {
|
||||
title: 'Danger',
|
||||
icon: `<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>`,
|
||||
confirmButtonClass: 'bg-red-glass'
|
||||
},
|
||||
info: {
|
||||
title: 'Information',
|
||||
icon: `<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>`,
|
||||
confirmButtonClass: 'bg-blue-glass'
|
||||
},
|
||||
success: {
|
||||
title: 'Success',
|
||||
icon: `<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>`,
|
||||
confirmButtonClass: 'bg-green-glass'
|
||||
},
|
||||
default: {
|
||||
title: 'Confirm Action',
|
||||
icon: `<svg class="w-6 h-6 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>`
|
||||
}
|
||||
},
|
||||
|
||||
// Create and show the modal
|
||||
show: function(options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Merge options with defaults and type config
|
||||
const config = { ...this.defaults, ...options };
|
||||
const typeConfig = this.typeConfigs[config.type] || this.typeConfigs.default;
|
||||
|
||||
// Apply type-specific config
|
||||
if (config.type !== 'default' && !options.title) {
|
||||
config.title = typeConfig.title;
|
||||
}
|
||||
if (!config.icon && typeConfig.icon) {
|
||||
config.icon = typeConfig.icon;
|
||||
}
|
||||
if (config.type !== 'default' && !options.confirmButtonClass) {
|
||||
config.confirmButtonClass = typeConfig.confirmButtonClass;
|
||||
}
|
||||
|
||||
// Get or create modal element
|
||||
let modal = document.getElementById('confirmationModal');
|
||||
if (!modal) {
|
||||
modal = this._createModalElement();
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Update modal content
|
||||
this._updateModalContent(modal, config);
|
||||
|
||||
// Set up event handlers
|
||||
const confirmBtn = modal.querySelector('[data-confirm-btn]');
|
||||
const cancelBtn = modal.querySelector('[data-cancel-btn]');
|
||||
const backdrop = modal.querySelector('.modal-backdrop');
|
||||
|
||||
// Clean up previous handlers
|
||||
const newConfirmHandler = () => {
|
||||
modal.close();
|
||||
if (config.onConfirm) {
|
||||
try {
|
||||
const result = config.onConfirm();
|
||||
if (result instanceof Promise) {
|
||||
result.then(resolve).catch(reject);
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
this._cleanup(modal);
|
||||
};
|
||||
|
||||
const newCancelHandler = () => {
|
||||
modal.close();
|
||||
if (config.onCancel) {
|
||||
try {
|
||||
const result = config.onCancel();
|
||||
if (result instanceof Promise) {
|
||||
result.then(() => resolve(false)).catch(reject);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
this._cleanup(modal);
|
||||
};
|
||||
|
||||
const escapeHandler = (e) => {
|
||||
if (config.closeOnEscape && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
newCancelHandler();
|
||||
} else if (e.key === 'Enter' && config.focusConfirm) {
|
||||
e.preventDefault();
|
||||
newConfirmHandler();
|
||||
}
|
||||
};
|
||||
|
||||
// Attach handlers
|
||||
confirmBtn.addEventListener('click', newConfirmHandler);
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', newCancelHandler);
|
||||
}
|
||||
document.addEventListener('keydown', escapeHandler);
|
||||
|
||||
// Store handlers for cleanup
|
||||
modal._handlers = {
|
||||
confirm: newConfirmHandler,
|
||||
cancel: newCancelHandler,
|
||||
escape: escapeHandler
|
||||
};
|
||||
|
||||
// Backdrop click handler
|
||||
if (config.closeOnBackdrop) {
|
||||
const backdropHandler = (e) => {
|
||||
if (e.target === modal) {
|
||||
newCancelHandler();
|
||||
}
|
||||
};
|
||||
modal.addEventListener('click', backdropHandler);
|
||||
modal._handlers.backdrop = backdropHandler;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
modal.showModal();
|
||||
|
||||
// Focus confirm button if specified
|
||||
if (config.focusConfirm) {
|
||||
setTimeout(() => confirmBtn.focus(), 100);
|
||||
} else if (cancelBtn) {
|
||||
setTimeout(() => cancelBtn.focus(), 100);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Create the modal DOM element
|
||||
_createModalElement: function() {
|
||||
const modal = document.createElement('dialog');
|
||||
modal.id = 'confirmationModal';
|
||||
modal.className = 'confirmation-modal p-0 bg-transparent border-0 outline-none rounded-lg shadow-2xl w-full max-w-md';
|
||||
modal.setAttribute('style', 'border: none; outline: none; padding: 0; margin: 0; background: transparent;');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content theme-glass rounded-lg shadow-xl border-0 outline-none ${this.defaults.width} mx-auto" style="background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(40px) saturate(200%); -webkit-backdrop-filter: blur(40px) saturate(200%); border: 1px solid var(--border-color-strong); box-shadow: var(--shadow-xl), inset 0 1px 0 rgba(255, 255, 255, 0.15); position: relative; overflow: hidden;">
|
||||
<div style="position: absolute; top: 0; left: 0; right: 0; height: 40%; background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0) 100%); pointer-events: none; border-radius: inherit; z-index: 0;"></div>
|
||||
<div style="position: relative; z-index: 1;">
|
||||
<div class="modal-header p-6 pb-4" style="border-bottom: 1px solid var(--border-color);">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="modal-icon flex-shrink-0"></div>
|
||||
<h3 class="modal-title text-xl font-bold flex-1" style="color: var(--text-primary);"></h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body p-6">
|
||||
<div class="modal-message" style="color: var(--text-secondary);"></div>
|
||||
</div>
|
||||
<div class="modal-footer p-6 pt-4 flex justify-end gap-3" style="border-top: 1px solid var(--border-color);">
|
||||
<button data-cancel-btn class="px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none"></button>
|
||||
<button data-confirm-btn class="px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return modal;
|
||||
},
|
||||
|
||||
// Update modal content based on config
|
||||
_updateModalContent: function(modal, config) {
|
||||
const titleEl = modal.querySelector('.modal-title');
|
||||
const messageEl = modal.querySelector('.modal-message');
|
||||
const iconEl = modal.querySelector('.modal-icon');
|
||||
const confirmBtn = modal.querySelector('[data-confirm-btn]');
|
||||
const cancelBtn = modal.querySelector('[data-cancel-btn]');
|
||||
const footer = modal.querySelector('.modal-footer');
|
||||
const content = modal.querySelector('.modal-content');
|
||||
|
||||
// Update title
|
||||
if (titleEl) {
|
||||
titleEl.textContent = config.title;
|
||||
}
|
||||
|
||||
// Update message
|
||||
if (messageEl) {
|
||||
if (config.allowHTML) {
|
||||
messageEl.innerHTML = config.message;
|
||||
} else {
|
||||
messageEl.textContent = config.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Update icon
|
||||
if (iconEl) {
|
||||
if (config.icon) {
|
||||
iconEl.innerHTML = config.icon;
|
||||
iconEl.classList.remove('hidden');
|
||||
} else {
|
||||
iconEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update buttons with glass styling
|
||||
if (confirmBtn) {
|
||||
confirmBtn.textContent = config.confirmText;
|
||||
confirmBtn.className = 'px-4 py-2 rounded-lg font-medium transition-all focus:outline-none btn-glass-primary';
|
||||
// Apply type-specific colors
|
||||
let bgColor, borderColor;
|
||||
if (config.type === 'warning') {
|
||||
bgColor = 'rgba(245, 158, 11, 0.3)';
|
||||
borderColor = 'rgba(245, 158, 11, 0.5)';
|
||||
} else if (config.type === 'danger') {
|
||||
bgColor = 'rgba(239, 68, 68, 0.3)';
|
||||
borderColor = 'rgba(239, 68, 68, 0.5)';
|
||||
} else if (config.type === 'info') {
|
||||
bgColor = 'rgba(59, 130, 246, 0.3)';
|
||||
borderColor = 'rgba(59, 130, 246, 0.5)';
|
||||
} else if (config.type === 'success') {
|
||||
bgColor = 'rgba(16, 185, 129, 0.3)';
|
||||
borderColor = 'rgba(16, 185, 129, 0.5)';
|
||||
} else {
|
||||
bgColor = 'rgba(99, 102, 241, 0.3)';
|
||||
borderColor = 'rgba(99, 102, 241, 0.5)';
|
||||
}
|
||||
confirmBtn.style.cssText = `
|
||||
background: ${bgColor};
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid ${borderColor};
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 6px -1px ${borderColor.replace('0.5', '0.2')}, 0 2px 4px -1px ${borderColor.replace('0.5', '0.1')}, inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`;
|
||||
confirmBtn.addEventListener('mouseenter', function() {
|
||||
this.style.background = bgColor.replace('0.3', '0.5');
|
||||
this.style.borderColor = borderColor.replace('0.5', '0.7');
|
||||
this.style.transform = 'translateY(-2px)';
|
||||
});
|
||||
confirmBtn.addEventListener('mouseleave', function() {
|
||||
this.style.background = bgColor;
|
||||
this.style.borderColor = borderColor;
|
||||
this.style.transform = 'translateY(0)';
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
if (config.showCancel) {
|
||||
cancelBtn.textContent = config.cancelText;
|
||||
cancelBtn.className = 'px-4 py-2 rounded-lg font-medium transition-all focus:outline-none btn-glass';
|
||||
cancelBtn.style.cssText = `
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
`;
|
||||
cancelBtn.addEventListener('mouseenter', function() {
|
||||
this.style.background = 'var(--bg-glass-hover)';
|
||||
this.style.borderColor = 'var(--border-color-strong)';
|
||||
this.style.boxShadow = 'var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08)';
|
||||
});
|
||||
cancelBtn.addEventListener('mouseleave', function() {
|
||||
this.style.background = 'var(--bg-glass)';
|
||||
this.style.borderColor = 'var(--border-color)';
|
||||
this.style.boxShadow = 'var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03)';
|
||||
});
|
||||
cancelBtn.classList.remove('hidden');
|
||||
} else {
|
||||
cancelBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update width
|
||||
if (content && config.width) {
|
||||
content.className = content.className.replace(/max-w-\w+/, '');
|
||||
content.classList.add(config.width);
|
||||
}
|
||||
},
|
||||
|
||||
// Clean up event handlers
|
||||
_cleanup: function(modal) {
|
||||
if (!modal._handlers) return;
|
||||
|
||||
const confirmBtn = modal.querySelector('[data-confirm-btn]');
|
||||
const cancelBtn = modal.querySelector('[data-cancel-btn]');
|
||||
|
||||
if (confirmBtn && modal._handlers.confirm) {
|
||||
confirmBtn.removeEventListener('click', modal._handlers.confirm);
|
||||
}
|
||||
if (cancelBtn && modal._handlers.cancel) {
|
||||
cancelBtn.removeEventListener('click', modal._handlers.cancel);
|
||||
}
|
||||
if (modal._handlers.escape) {
|
||||
document.removeEventListener('keydown', modal._handlers.escape);
|
||||
}
|
||||
if (modal._handlers.backdrop) {
|
||||
modal.removeEventListener('click', modal._handlers.backdrop);
|
||||
}
|
||||
|
||||
delete modal._handlers;
|
||||
},
|
||||
|
||||
// Convenience methods for common types
|
||||
warning: function(message, options = {}) {
|
||||
return this.show({ ...options, message, type: 'warning' });
|
||||
},
|
||||
|
||||
danger: function(message, options = {}) {
|
||||
return this.show({ ...options, message, type: 'danger' });
|
||||
},
|
||||
|
||||
info: function(message, options = {}) {
|
||||
return this.show({ ...options, message, type: 'info' });
|
||||
},
|
||||
|
||||
success: function(message, options = {}) {
|
||||
return this.show({ ...options, message, type: 'success' });
|
||||
},
|
||||
|
||||
// Simple confirm replacement (backward compatible)
|
||||
confirm: function(message, options = {}) {
|
||||
return this.show({ ...options, message });
|
||||
},
|
||||
|
||||
// Alert-style modal (single button, no cancel)
|
||||
alert: function(message, options = {}) {
|
||||
return this.show({
|
||||
...options,
|
||||
message,
|
||||
showCancel: false,
|
||||
confirmText: options.confirmText || 'OK',
|
||||
type: options.type || 'info',
|
||||
focusConfirm: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Make it globally available
|
||||
window.ConfirmationModal = ConfirmationModal;
|
||||
|
||||
// Also provide a simple showConfirm function for backward compatibility
|
||||
window.showConfirm = function(message, callback, options = {}) {
|
||||
return ConfirmationModal.show({
|
||||
...options,
|
||||
message,
|
||||
onConfirm: callback
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Global infinite scroll state for all tabs
|
||||
window.infiniteScrollState = {};
|
||||
|
||||
// Core UI functions - generic fetch, filter, pagination
|
||||
async function genericFetch(tabId, shouldRender = true) {
|
||||
const config = window.tabs[tabId];
|
||||
if (!config) return;
|
||||
try {
|
||||
const res = await fetch(config.api);
|
||||
let data = await res.json();
|
||||
if (config.postFetch) {
|
||||
const result = config.postFetch(data);
|
||||
// Handle both sync and async postFetch functions
|
||||
data = result instanceof Promise ? await result : result;
|
||||
}
|
||||
|
||||
// Special handling for settings tab
|
||||
if (tabId === 'settings') {
|
||||
window[config.dataKey] = data;
|
||||
if (shouldRender && window.renderSettings) await window.renderSettings(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.sort && Array.isArray(data)) data.sort(config.sort);
|
||||
window[config.dataKey] = data;
|
||||
|
||||
// Reset infinite scroll state when fetching fresh data
|
||||
if (window.infiniteScrollState && window.infiniteScrollState[tabId]) {
|
||||
window.infiniteScrollState[tabId].loadedCount = 0;
|
||||
window.infiniteScrollState[tabId].lastQuery = '';
|
||||
// Disconnect existing observer
|
||||
if (window.infiniteScrollState[tabId].observer) {
|
||||
window.infiniteScrollState[tabId].observer.disconnect();
|
||||
window.infiniteScrollState[tabId].observer = null;
|
||||
}
|
||||
// Clear container
|
||||
const container = document.getElementById(config.containerId);
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRender) {
|
||||
genericFilter(tabId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch ${tabId}:`, err);
|
||||
if (window.showNotification) window.showNotification(`Failed to load ${tabId}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function genericFilter(tabId) {
|
||||
const config = window.tabs[tabId];
|
||||
if (!config || !config.filter) return;
|
||||
const searchEl = document.getElementById(config.searchId);
|
||||
if (!searchEl) return;
|
||||
const query = searchEl.value.toLowerCase();
|
||||
const data = window[config.dataKey];
|
||||
if (!data || !Array.isArray(data)) return;
|
||||
const filtered = data.filter(item => config.filter(item, query));
|
||||
window[config.filteredKey] = filtered;
|
||||
|
||||
// Use infinite scroll for all tabs
|
||||
genericRenderInfiniteScroll(tabId);
|
||||
}
|
||||
|
||||
// Global infinite scroll renderer
|
||||
function genericRenderInfiniteScroll(tabId) {
|
||||
const config = window.tabs[tabId];
|
||||
if (!config) {
|
||||
console.warn(`No config found for tab: ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const data = window[config.filteredKey] || window[config.dataKey];
|
||||
if (!data) {
|
||||
console.warn(`No data found for tab: ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (config.preRender) config.preRender(data.length);
|
||||
|
||||
// Update count if countId is specified
|
||||
if (config.countId) {
|
||||
const countEl = document.getElementById(config.countId);
|
||||
if (countEl) {
|
||||
const totalCount = data.length;
|
||||
const searchEl = document.getElementById(config.searchId);
|
||||
const searchQuery = searchEl ? searchEl.value : '';
|
||||
if (searchQuery) {
|
||||
countEl.textContent = `${totalCount.toLocaleString()} (filtered)`;
|
||||
} else {
|
||||
countEl.textContent = totalCount.toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById(config.containerId);
|
||||
if (!container) {
|
||||
console.warn(`Container not found: ${config.containerId} for tab: ${tabId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize infinite scroll state for this tab
|
||||
if (!window.infiniteScrollState[tabId]) {
|
||||
window.infiniteScrollState[tabId] = {
|
||||
loadedCount: 0,
|
||||
observer: null,
|
||||
batchSize: calculateBatchSize(tabId),
|
||||
lastQuery: ''
|
||||
};
|
||||
}
|
||||
|
||||
const state = window.infiniteScrollState[tabId];
|
||||
const searchEl = document.getElementById(config.searchId);
|
||||
const query = searchEl ? searchEl.value.toLowerCase() : '';
|
||||
const isNewSearch = state.lastQuery !== query;
|
||||
|
||||
// Reset if new search
|
||||
if (isNewSearch) {
|
||||
state.loadedCount = 0;
|
||||
state.lastQuery = query;
|
||||
state.batchSize = calculateBatchSize(tabId);
|
||||
container.innerHTML = '';
|
||||
|
||||
// Disconnect existing observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty state
|
||||
if (data.length === 0) {
|
||||
container.innerHTML = '';
|
||||
const isTable = container.tagName === 'TBODY';
|
||||
if (isTable) {
|
||||
const emptyRow = document.createElement('tr');
|
||||
emptyRow.className = 'border-b';
|
||||
const colCount = tabId === 'host-servers' ? 7 : (tabId === 'host-clients' ? 6 : (tabId === 'domains' ? 4 : 3));
|
||||
emptyRow.innerHTML = `<td colspan="${colCount}" class="p-8 text-center theme-text-tertiary">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-4xl">📭</span>
|
||||
<span class="text-lg font-semibold">No ${tabId === 'host-servers' ? 'servers' : tabId === 'host-clients' ? 'clients' : 'items'} found</span>
|
||||
<span class="text-sm">${tabId === 'host-servers' ? 'Create a server to get started' : tabId === 'host-clients' ? 'Create a client to get started' : 'Try adjusting your search'}</span>
|
||||
</div>
|
||||
</td>`;
|
||||
container.appendChild(emptyRow);
|
||||
} else {
|
||||
// For list containers (ul/ol)
|
||||
const emptyItem = document.createElement('li');
|
||||
emptyItem.className = 'p-8 text-center theme-text-tertiary';
|
||||
emptyItem.innerHTML = `
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-4xl">📭</span>
|
||||
<span class="text-lg font-semibold">No ${tabId === 'peers' ? 'peers' : 'items'} found</span>
|
||||
<span class="text-sm">Try adjusting your search</span>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(emptyItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Load initial batch or next batch
|
||||
loadNextBatch(tabId, data);
|
||||
}
|
||||
|
||||
// Calculate batch size based on tab type and viewport
|
||||
function calculateBatchSize(tabId) {
|
||||
const viewportHeight = window.innerHeight;
|
||||
// Estimate row/item height (approximately 50-80px per item including padding)
|
||||
const estimatedItemHeight = tabId === 'peers' ? 100 : (tabId === 'certs' ? 70 : 60);
|
||||
const visibleItems = Math.floor((viewportHeight - 300) / estimatedItemHeight);
|
||||
// Load 2-3x visible items per batch
|
||||
return Math.max(10, Math.min(50, visibleItems * 2.5));
|
||||
}
|
||||
|
||||
// Load next batch of items
|
||||
function loadNextBatch(tabId, data) {
|
||||
const config = window.tabs[tabId];
|
||||
if (!config) return;
|
||||
|
||||
const state = window.infiniteScrollState[tabId];
|
||||
if (!state) return;
|
||||
|
||||
const container = document.getElementById(config.containerId);
|
||||
if (!container) return;
|
||||
|
||||
const start = state.loadedCount;
|
||||
const end = Math.min(start + state.batchSize, data.length);
|
||||
const batch = data.slice(start, end);
|
||||
|
||||
if (batch.length === 0) {
|
||||
// No more data to load
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
// Remove sentinel if exists (but keep config sentinel)
|
||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Render batch
|
||||
batch.forEach(item => {
|
||||
const element = config.renderItem(item);
|
||||
container.appendChild(element);
|
||||
});
|
||||
|
||||
state.loadedCount = end;
|
||||
|
||||
// Setup IntersectionObserver for next batch
|
||||
if (end < data.length) {
|
||||
setupInfiniteScrollObserver(tabId, container);
|
||||
} else {
|
||||
// All data loaded, disconnect observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
state.observer = null;
|
||||
}
|
||||
// Remove sentinel if exists (but keep config sentinel)
|
||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Setup IntersectionObserver to detect when to load more
|
||||
function setupInfiniteScrollObserver(tabId, container) {
|
||||
const config = window.tabs[tabId];
|
||||
if (!config) return;
|
||||
|
||||
const state = window.infiniteScrollState[tabId];
|
||||
if (!state) return;
|
||||
|
||||
// Create or get sentinel element
|
||||
// Check for existing sentinel ID in config first
|
||||
let sentinel = config.sentinelId ? document.getElementById(config.sentinelId) : null;
|
||||
if (!sentinel) {
|
||||
// Check for existing sentinel by class
|
||||
sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
}
|
||||
if (!sentinel) {
|
||||
// Create new sentinel
|
||||
const isTable = container.tagName === 'TBODY';
|
||||
if (isTable) {
|
||||
sentinel = document.createElement('tr');
|
||||
sentinel.className = 'infinite-scroll-sentinel';
|
||||
sentinel.innerHTML = `<td colspan="${container.querySelector('tr')?.cells.length || 2}" style="height: 1px; padding: 0;"></td>`;
|
||||
} else {
|
||||
sentinel = document.createElement('div');
|
||||
sentinel.className = 'infinite-scroll-sentinel';
|
||||
sentinel.style.height = '1px';
|
||||
sentinel.style.width = '100%';
|
||||
sentinel.style.pointerEvents = 'none';
|
||||
}
|
||||
container.appendChild(sentinel);
|
||||
}
|
||||
|
||||
// Find the scrollable container (must be a parent with overflow-y-auto or overflow-auto)
|
||||
const scrollContainer = container.closest('.overflow-y-auto, .overflow-auto');
|
||||
|
||||
if (!scrollContainer) {
|
||||
console.warn(`No scrollable container found for tab: ${tabId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect existing observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
}
|
||||
|
||||
state.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const data = window[config.filteredKey] || window[config.dataKey];
|
||||
if (data) {
|
||||
loadNextBatch(tabId, data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: scrollContainer,
|
||||
rootMargin: '200px' // Start loading 200px before reaching the sentinel
|
||||
});
|
||||
|
||||
state.observer.observe(sentinel);
|
||||
}
|
||||
|
||||
// Legacy entries lazy scroll - now uses generic infinite scroll
|
||||
function renderEntriesLazy() {
|
||||
const config = window.tabs.entries;
|
||||
if (!config) return;
|
||||
|
||||
const data = window[config.filteredKey] || window[config.dataKey];
|
||||
if (!data) return;
|
||||
|
||||
// Update count display
|
||||
const countEl = document.getElementById(config.countId);
|
||||
if (countEl) {
|
||||
const totalCount = data.length;
|
||||
const searchEl = document.getElementById(config.searchId);
|
||||
const searchQuery = searchEl ? searchEl.value : '';
|
||||
if (searchQuery) {
|
||||
countEl.textContent = `${totalCount.toLocaleString()} (filtered)`;
|
||||
} else {
|
||||
countEl.textContent = totalCount.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// Use generic infinite scroll
|
||||
genericRenderInfiniteScroll('entries');
|
||||
}
|
||||
|
||||
// Filter functions for each tab
|
||||
function filterDomains() { genericFilter('domains'); }
|
||||
function filterEntries() { genericFilter('entries'); }
|
||||
function filterPeers() { genericFilter('peers'); }
|
||||
function filterCerts() { genericFilter('certs'); }
|
||||
function filterInterfaces() { genericFilter('interfaces'); }
|
||||
function filterLocalDNS() { genericFilter('local-dns'); }
|
||||
function filterDnsConflicts() { genericFilter('dns-conflicts'); }
|
||||
function filterHolesailServers() { genericFilter('host-servers'); }
|
||||
function filterHolesailClients() { genericFilter('host-clients'); }
|
||||
|
||||
// Make functions globally accessible
|
||||
window.genericFetch = genericFetch;
|
||||
window.genericFilter = genericFilter;
|
||||
window.genericRenderInfiniteScroll = genericRenderInfiniteScroll;
|
||||
window.renderEntriesLazy = renderEntriesLazy;
|
||||
window.filterDomains = filterDomains;
|
||||
window.filterEntries = filterEntries;
|
||||
window.filterPeers = filterPeers;
|
||||
window.filterCerts = filterCerts;
|
||||
window.filterInterfaces = filterInterfaces;
|
||||
window.filterLocalDNS = filterLocalDNS;
|
||||
window.filterDnsConflicts = filterDnsConflicts;
|
||||
window.filterHolesailServers = filterHolesailServers;
|
||||
window.filterHolesailClients = filterHolesailClients;
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
// Diagnostics UI functions
|
||||
|
||||
let diagnosticsResults = [];
|
||||
let activeStreams = new Map(); // Track active streaming requests for cancellation
|
||||
let handlersSetup = false; // Track if Enter key handlers have been set up
|
||||
|
||||
// Run DNS lookup
|
||||
async function runDnsLookup() {
|
||||
const domainEl = document.getElementById('dns-lookup-domain');
|
||||
const typeEl = document.getElementById('dns-lookup-type');
|
||||
const buttonEl = document.querySelector('button[onclick="runDnsLookup()"]');
|
||||
if (!domainEl || !typeEl) {
|
||||
console.error('DNS lookup elements not found');
|
||||
if (window.showNotification) window.showNotification('DNS lookup form not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = domainEl.value.trim();
|
||||
const type = typeEl.value;
|
||||
|
||||
if (!domain) {
|
||||
if (window.showNotification) window.showNotification('Please enter a domain', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Looking up...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/dns-lookup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, type })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
displayDiagnosticResult('DNS Lookup', result);
|
||||
} catch (err) {
|
||||
console.error('DNS lookup failed:', err);
|
||||
if (window.showNotification) window.showNotification('DNS lookup failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
// Restore button
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.innerHTML = 'Lookup';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run ping
|
||||
async function runPing() {
|
||||
const targetEl = document.getElementById('ping-target');
|
||||
const countEl = document.getElementById('ping-count');
|
||||
const buttonEl = document.querySelector('button[onclick="runPing()"]');
|
||||
if (!targetEl) {
|
||||
console.error('Ping elements not found');
|
||||
if (window.showNotification) window.showNotification('Ping form not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetEl.value.trim();
|
||||
const count = parseInt(countEl?.value || '4', 10);
|
||||
|
||||
if (!target) {
|
||||
if (window.showNotification) window.showNotification('Please enter a target', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Pinging...';
|
||||
}
|
||||
|
||||
// Create result container for streaming
|
||||
const resultId = `ping-${Date.now()}`;
|
||||
const resultDiv = createStreamingResultContainer('Ping', resultId, () => cancelStream(resultId));
|
||||
const outputContainer = resultDiv.querySelector('.streaming-output');
|
||||
|
||||
let reader = null;
|
||||
let cancelled = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/ping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target, count, stream: true })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
// Store stream for cancellation
|
||||
reader = response.body.getReader();
|
||||
activeStreams.set(resultId, { reader, cancelled: false });
|
||||
|
||||
// Stream the response
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let fullOutput = '';
|
||||
let fullError = '';
|
||||
let finalResult = null;
|
||||
|
||||
while (true) {
|
||||
const streamInfo = activeStreams.get(resultId);
|
||||
if (streamInfo && streamInfo.cancelled) {
|
||||
cancelled = true;
|
||||
if (outputContainer) {
|
||||
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||
}
|
||||
if (resultDiv) {
|
||||
updateStreamingResultStatus(resultDiv, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let readResult;
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch (err) {
|
||||
// Stream was cancelled or error occurred
|
||||
if (err.name === 'AbortError' || activeStreams.get(resultId)?.cancelled) {
|
||||
cancelled = true;
|
||||
if (outputContainer) {
|
||||
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||
}
|
||||
if (resultDiv) {
|
||||
updateStreamingResultStatus(resultDiv, false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const { done, value } = readResult;
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line);
|
||||
|
||||
if (chunk.type === 'output') {
|
||||
fullOutput += chunk.data + '\n';
|
||||
appendStreamingOutput(outputContainer, chunk.data, 'output');
|
||||
} else if (chunk.type === 'error') {
|
||||
fullError += chunk.data;
|
||||
appendStreamingOutput(outputContainer, chunk.data, 'error');
|
||||
} else if (chunk.type === 'complete') {
|
||||
finalResult = chunk;
|
||||
updateStreamingResultStatus(resultDiv, chunk.success);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
activeStreams.delete(resultId);
|
||||
|
||||
// Display final result if not cancelled
|
||||
if (!cancelled && finalResult) {
|
||||
displayDiagnosticResult('Ping', {
|
||||
success: finalResult.success,
|
||||
target,
|
||||
count,
|
||||
output: fullOutput,
|
||||
error: fullError || null,
|
||||
responseTime: finalResult.responseTime
|
||||
});
|
||||
// Remove streaming container
|
||||
resultDiv.remove();
|
||||
} else if (cancelled) {
|
||||
// Keep the streaming container showing cancelled state
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ping failed:', err);
|
||||
if (!cancelled) {
|
||||
if (window.showNotification) window.showNotification('Ping failed: ' + err.message, 'error');
|
||||
}
|
||||
if (resultDiv && resultDiv.parentNode && !cancelled) {
|
||||
resultDiv.remove();
|
||||
}
|
||||
activeStreams.delete(resultId);
|
||||
} finally {
|
||||
// Restore button
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.innerHTML = 'Ping';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run traceroute
|
||||
async function runTraceroute() {
|
||||
const targetEl = document.getElementById('traceroute-target');
|
||||
const buttonEl = document.querySelector('button[onclick="runTraceroute()"]');
|
||||
if (!targetEl) {
|
||||
console.error('Traceroute elements not found');
|
||||
if (window.showNotification) window.showNotification('Traceroute form not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetEl.value.trim();
|
||||
|
||||
if (!target) {
|
||||
if (window.showNotification) window.showNotification('Please enter a target', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Tracing...';
|
||||
}
|
||||
|
||||
// Create result container for streaming
|
||||
const resultId = `traceroute-${Date.now()}`;
|
||||
const resultDiv = createStreamingResultContainer('Traceroute', resultId, () => cancelStream(resultId));
|
||||
const outputContainer = resultDiv.querySelector('.streaming-output');
|
||||
|
||||
let reader = null;
|
||||
let cancelled = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/traceroute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target, stream: true })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
// Store stream for cancellation
|
||||
reader = response.body.getReader();
|
||||
activeStreams.set(resultId, { reader, cancelled: false });
|
||||
|
||||
// Stream the response
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let fullOutput = '';
|
||||
let fullError = '';
|
||||
let finalResult = null;
|
||||
|
||||
while (true) {
|
||||
const streamInfo = activeStreams.get(resultId);
|
||||
if (streamInfo && streamInfo.cancelled) {
|
||||
cancelled = true;
|
||||
if (outputContainer) {
|
||||
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||
}
|
||||
if (resultDiv) {
|
||||
updateStreamingResultStatus(resultDiv, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let readResult;
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch (err) {
|
||||
// Stream was cancelled or error occurred
|
||||
if (err.name === 'AbortError' || activeStreams.get(resultId)?.cancelled) {
|
||||
cancelled = true;
|
||||
if (outputContainer) {
|
||||
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||
}
|
||||
if (resultDiv) {
|
||||
updateStreamingResultStatus(resultDiv, false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const { done, value } = readResult;
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line);
|
||||
|
||||
if (chunk.type === 'output') {
|
||||
fullOutput += chunk.data + '\n';
|
||||
appendStreamingOutput(outputContainer, chunk.data, 'output');
|
||||
} else if (chunk.type === 'error') {
|
||||
fullError += chunk.data;
|
||||
appendStreamingOutput(outputContainer, chunk.data, 'error');
|
||||
} else if (chunk.type === 'complete') {
|
||||
finalResult = chunk;
|
||||
updateStreamingResultStatus(resultDiv, chunk.success);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
activeStreams.delete(resultId);
|
||||
|
||||
// Display final result if not cancelled
|
||||
if (!cancelled && finalResult) {
|
||||
displayDiagnosticResult('Traceroute', {
|
||||
success: finalResult.success,
|
||||
target,
|
||||
output: fullOutput,
|
||||
error: fullError || null,
|
||||
responseTime: finalResult.responseTime
|
||||
});
|
||||
// Remove streaming container
|
||||
resultDiv.remove();
|
||||
} else if (cancelled) {
|
||||
// Keep the streaming container showing cancelled state
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Traceroute failed:', err);
|
||||
if (!cancelled) {
|
||||
if (window.showNotification) window.showNotification('Traceroute failed: ' + err.message, 'error');
|
||||
}
|
||||
if (resultDiv && resultDiv.parentNode && !cancelled) {
|
||||
resultDiv.remove();
|
||||
}
|
||||
activeStreams.delete(resultId);
|
||||
} finally {
|
||||
// Restore button
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.innerHTML = 'Traceroute';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection
|
||||
async function testConnection() {
|
||||
const domainEl = document.getElementById('connection-domain');
|
||||
const portEl = document.getElementById('connection-port');
|
||||
const buttonEl = document.querySelector('button[onclick="testConnection()"]');
|
||||
if (!domainEl || !portEl) {
|
||||
console.error('Connection test elements not found');
|
||||
if (window.showNotification) window.showNotification('Connection test form not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = domainEl.value.trim();
|
||||
const port = parseInt(portEl.value, 10);
|
||||
|
||||
if (!domain || !port || isNaN(port)) {
|
||||
if (window.showNotification) window.showNotification('Please enter a valid domain and port', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Testing...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/connection-test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, port })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
displayDiagnosticResult('Connection Test', result);
|
||||
} catch (err) {
|
||||
console.error('Connection test failed:', err);
|
||||
if (window.showNotification) window.showNotification('Connection test failed: ' + err.message, 'error');
|
||||
} finally {
|
||||
// Restore button
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.innerHTML = 'Test Connection';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch bandwidth stats
|
||||
async function fetchBandwidth() {
|
||||
const buttonEl = document.querySelector('button[onclick="fetchBandwidth()"]');
|
||||
|
||||
// Show loading indicator
|
||||
if (buttonEl) {
|
||||
// Store original HTML, not just text, since button might have HTML content
|
||||
if (!buttonEl.dataset.originalHtml) {
|
||||
buttonEl.dataset.originalHtml = buttonEl.innerHTML;
|
||||
}
|
||||
const originalHtml = buttonEl.dataset.originalHtml;
|
||||
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.innerHTML = '<span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>Loading...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/bandwidth');
|
||||
const result = await response.json();
|
||||
displayBandwidthStats(result);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch bandwidth:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to fetch bandwidth stats', 'error');
|
||||
} finally {
|
||||
// Restore button
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.innerHTML = originalHtml;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics/bandwidth');
|
||||
const result = await response.json();
|
||||
displayBandwidthStats(result);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch bandwidth:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to fetch bandwidth stats', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display diagnostic result
|
||||
function displayDiagnosticResult(tool, result) {
|
||||
const container = document.getElementById('diagnostics-results');
|
||||
if (!container) return;
|
||||
|
||||
const resultId = `result-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const resultDiv = document.createElement('div');
|
||||
resultDiv.id = resultId;
|
||||
resultDiv.className = 'mb-4 p-4 theme-card rounded-lg';
|
||||
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const successClass = result.success ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400';
|
||||
const successText = result.success ? 'Success' : 'Failed';
|
||||
|
||||
let content = `
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h4 class="font-semibold">${tool} - ${timestamp}</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 rounded text-sm font-semibold ${successClass}">${successText}</span>
|
||||
<button id="close-btn-${resultId}" class="ml-2 px-2 py-1 text-xs bg-gray-500 text-white rounded hover:bg-gray-600">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (result.error) {
|
||||
content += `<p class="text-red-600 dark:text-red-400 mb-2">Error: ${result.error}</p>`;
|
||||
}
|
||||
|
||||
if (result.output) {
|
||||
content += `<pre class="bg-black text-green-400 p-3 rounded text-sm overflow-x-auto">${escapeHtml(result.output)}</pre>`;
|
||||
}
|
||||
|
||||
if (result.results && Array.isArray(result.results)) {
|
||||
content += `<div class="mt-2"><strong>Results:</strong><pre class="theme-glass p-2 rounded text-sm">${JSON.stringify(result.results, null, 2)}</pre></div>`;
|
||||
}
|
||||
|
||||
if (result.domain) {
|
||||
content += `<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">Domain: ${result.domain}${result.type ? ` (${result.type})` : ''}${result.ip ? ` → ${result.ip}` : ''}${result.port ? `:${result.port}` : ''}</p>`;
|
||||
}
|
||||
|
||||
if (result.responseTime) {
|
||||
content += `<p class="text-sm text-gray-600 dark:text-gray-400">Response time: ${result.responseTime}ms</p>`;
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = content;
|
||||
|
||||
// Attach close button event listener
|
||||
const closeBtn = resultDiv.querySelector(`#close-btn-${resultId}`);
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
closeDiagnosticResult(resultId);
|
||||
});
|
||||
}
|
||||
|
||||
container.insertBefore(resultDiv, container.firstChild);
|
||||
|
||||
// Keep only last 10 results
|
||||
while (container.children.length > 10) {
|
||||
container.removeChild(container.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
// Display bandwidth stats
|
||||
function displayBandwidthStats(result) {
|
||||
const container = document.getElementById('bandwidth-stats');
|
||||
if (!container) return;
|
||||
|
||||
if (result.note) {
|
||||
container.innerHTML = `<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">${result.note}</p>`;
|
||||
}
|
||||
|
||||
const interfaces = Object.values(result.interfaces || {});
|
||||
if (interfaces.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No network interfaces found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = interfaces.map(iface => `
|
||||
<div class="mb-4 p-4 theme-card rounded-lg">
|
||||
<h4 class="font-semibold mb-2">${iface.name}</h4>
|
||||
<div class="space-y-1 text-sm">
|
||||
${iface.addresses.map(addr => `
|
||||
<div class="flex justify-between">
|
||||
<span>${addr.address}</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">${addr.family} ${addr.internal ? '(internal)' : ''}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Cancel stream
|
||||
function cancelStream(resultId) {
|
||||
console.log('Cancelling stream:', resultId);
|
||||
const streamInfo = activeStreams.get(resultId);
|
||||
if (streamInfo) {
|
||||
streamInfo.cancelled = true;
|
||||
if (streamInfo.reader) {
|
||||
streamInfo.reader.cancel().catch(err => {
|
||||
console.log('Stream cancellation error (expected):', err);
|
||||
});
|
||||
}
|
||||
// Update the UI to show cancelled state
|
||||
const resultDiv = document.getElementById(resultId);
|
||||
if (resultDiv) {
|
||||
const outputContainer = resultDiv.querySelector('.streaming-output');
|
||||
if (outputContainer) {
|
||||
appendStreamingOutput(outputContainer, '\n[Cancelled by user]', 'error');
|
||||
}
|
||||
updateStreamingResultStatus(resultDiv, false);
|
||||
}
|
||||
activeStreams.delete(resultId);
|
||||
} else {
|
||||
console.log('Stream not found:', resultId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create streaming result container
|
||||
function createStreamingResultContainer(tool, resultId, onCancel) {
|
||||
const container = document.getElementById('diagnostics-results');
|
||||
if (!container) return null;
|
||||
|
||||
const resultDiv = document.createElement('div');
|
||||
resultDiv.id = resultId;
|
||||
resultDiv.className = 'mb-4 p-4 theme-card rounded-lg';
|
||||
|
||||
const timestamp = new Date().toLocaleString();
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h4 class="font-semibold">${tool} - ${timestamp} <span class="text-sm text-gray-600 dark:text-gray-400">(Streaming...)</span></h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 rounded text-sm font-semibold bg-yellow-500" style="color: var(--text-primary);">
|
||||
<span class="inline-block animate-spin rounded-full h-3 w-3 border-t-2 border-yellow-800 dark:border-yellow-200 mr-1"></span>Running
|
||||
</span>
|
||||
${onCancel ? `<button id="cancel-btn-${resultId}" class="ml-2 px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">Cancel</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="streaming-output bg-black text-green-400 p-3 rounded text-sm overflow-x-auto font-mono max-h-64 overflow-y-auto" style="min-height: 100px;"></div>
|
||||
`;
|
||||
|
||||
// Attach event listener to cancel button
|
||||
if (onCancel) {
|
||||
const cancelBtn = resultDiv.querySelector(`#cancel-btn-${resultId}`);
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
cancelStream(resultId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
container.insertBefore(resultDiv, container.firstChild);
|
||||
|
||||
// Keep only last 5 streaming results
|
||||
while (container.children.length > 5) {
|
||||
container.removeChild(container.lastChild);
|
||||
}
|
||||
|
||||
return resultDiv;
|
||||
}
|
||||
|
||||
// Append streaming output
|
||||
function appendStreamingOutput(container, text, type) {
|
||||
if (!container) return;
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = type === 'error' ? 'text-red-400' : 'text-green-400';
|
||||
line.textContent = text;
|
||||
container.appendChild(line);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// Update streaming result status
|
||||
function updateStreamingResultStatus(resultDiv, success) {
|
||||
if (!resultDiv) return;
|
||||
|
||||
const statusEl = resultDiv.querySelector('.font-semibold span');
|
||||
const badgeEl = resultDiv.querySelector('.px-2.py-1');
|
||||
const buttonContainer = resultDiv.querySelector('.flex.items-center.gap-2');
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '(Complete)';
|
||||
}
|
||||
|
||||
if (badgeEl) {
|
||||
badgeEl.className = success
|
||||
? 'px-2 py-1 rounded text-sm font-semibold bg-green-500'
|
||||
: 'px-2 py-1 rounded text-sm font-semibold bg-red-500';
|
||||
badgeEl.style.color = 'var(--text-primary)';
|
||||
badgeEl.innerHTML = success ? 'Success' : 'Failed';
|
||||
}
|
||||
|
||||
// Replace cancel button with close button
|
||||
if (buttonContainer) {
|
||||
const cancelBtn = buttonContainer.querySelector('button[id^="cancel-btn-"]');
|
||||
if (cancelBtn) {
|
||||
const resultId = resultDiv.id;
|
||||
cancelBtn.remove();
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.id = `close-btn-${resultId}`;
|
||||
closeBtn.className = 'ml-2 px-2 py-1 text-xs bg-gray-500 text-white rounded hover:bg-gray-600';
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
closeDiagnosticResult(resultId);
|
||||
});
|
||||
buttonContainer.appendChild(closeBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close/remove diagnostic result
|
||||
function closeDiagnosticResult(resultId) {
|
||||
const resultDiv = document.getElementById(resultId);
|
||||
if (resultDiv && resultDiv.parentNode) {
|
||||
resultDiv.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Setup Enter key handlers for diagnostics forms
|
||||
function setupDiagnosticsEnterHandlers() {
|
||||
// Return early if handlers have already been set up
|
||||
if (handlersSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
// DNS Lookup - Enter on domain input
|
||||
const dnsDomainInput = document.getElementById('dns-lookup-domain');
|
||||
if (dnsDomainInput) {
|
||||
dnsDomainInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runDnsLookup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ping - Enter on target or count input
|
||||
const pingTargetInput = document.getElementById('ping-target');
|
||||
const pingCountInput = document.getElementById('ping-count');
|
||||
if (pingTargetInput) {
|
||||
pingTargetInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runPing();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (pingCountInput) {
|
||||
pingCountInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runPing();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Traceroute - Enter on target input
|
||||
const tracerouteTargetInput = document.getElementById('traceroute-target');
|
||||
if (tracerouteTargetInput) {
|
||||
tracerouteTargetInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runTraceroute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Connection Test - Enter on domain or port input
|
||||
const connectionDomainInput = document.getElementById('connection-domain');
|
||||
const connectionPortInput = document.getElementById('connection-port');
|
||||
if (connectionDomainInput) {
|
||||
connectionDomainInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
testConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (connectionPortInput) {
|
||||
connectionPortInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
testConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mark handlers as set up
|
||||
handlersSetup = true;
|
||||
}
|
||||
|
||||
// Render diagnostics
|
||||
function renderDiagnostics() {
|
||||
// Load bandwidth stats on render
|
||||
fetchBandwidth();
|
||||
// Setup Enter key handlers
|
||||
setupDiagnosticsEnterHandlers();
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.runDnsLookup = runDnsLookup;
|
||||
window.runPing = runPing;
|
||||
window.runTraceroute = runTraceroute;
|
||||
window.testConnection = testConnection;
|
||||
window.fetchBandwidth = fetchBandwidth;
|
||||
window.renderDiagnostics = renderDiagnostics;
|
||||
window.cancelStream = cancelStream;
|
||||
window.closeDiagnosticResult = closeDiagnosticResult;
|
||||
|
||||
// Setup Enter key handlers when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupDiagnosticsEnterHandlers);
|
||||
} else {
|
||||
// DOM is already loaded
|
||||
setupDiagnosticsEnterHandlers();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Domains UI functions
|
||||
function renderDnsConflicts() {
|
||||
if (window.genericFilter) window.genericFilter('dns-conflicts');
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
const modal = document.getElementById('addDomainModal');
|
||||
if (modal) modal.showModal();
|
||||
}
|
||||
|
||||
async function submitAddDomain() {
|
||||
const domainEl = document.getElementById('modal-domain');
|
||||
const hashEl = document.getElementById('modal-hash');
|
||||
const sslEl = document.getElementById('modal-ssl');
|
||||
if (!domainEl || !hashEl) return;
|
||||
|
||||
const domain = domainEl.value.trim();
|
||||
const hash = hashEl.value.trim();
|
||||
const ssl = sslEl ? sslEl.checked : false;
|
||||
try {
|
||||
const response = await fetch('/api/add-domain', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, hash, ssl })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Domain added successfully');
|
||||
const modal = document.getElementById('addDomainModal');
|
||||
if (modal) modal.close();
|
||||
domainEl.value = '';
|
||||
hashEl.value = '';
|
||||
if (sslEl) sslEl.checked = false;
|
||||
if (window.genericFetch) window.genericFetch('domains', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to add domain:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to add domain: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function removeDomain(domain) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Remove ${domain}?`, async () => {
|
||||
try {
|
||||
const response = await fetch('/api/remove-domain', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Domain removed successfully');
|
||||
if (window.genericFetch) window.genericFetch('domains', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove domain:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to remove domain: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.renderDnsConflicts = renderDnsConflicts;
|
||||
window.openAddModal = openAddModal;
|
||||
window.submitAddDomain = submitAddDomain;
|
||||
window.removeDomain = removeDomain;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Health monitoring UI functions
|
||||
|
||||
let healthData = null;
|
||||
let healthUpdateInterval = null;
|
||||
|
||||
// Fetch health data
|
||||
async function fetchHealth() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch health data');
|
||||
}
|
||||
healthData = await response.json();
|
||||
return healthData;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch health:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to load health data', 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Render health dashboard
|
||||
async function renderHealth() {
|
||||
const data = await fetchHealth();
|
||||
if (!data) return;
|
||||
|
||||
updateHealthStatus(data);
|
||||
renderServiceCards(data);
|
||||
updateHealthHistory(data);
|
||||
}
|
||||
|
||||
// Update health status display
|
||||
function updateHealthStatus(data) {
|
||||
const statusEl = document.getElementById('health-status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = data.status === 'healthy' ? 'Healthy' : 'Degraded';
|
||||
statusEl.className = data.status === 'healthy'
|
||||
? 'text-2xl font-bold text-green-600 dark:text-green-400'
|
||||
: 'text-2xl font-bold text-yellow-600 dark:text-yellow-400';
|
||||
}
|
||||
|
||||
const uptimeEl = document.getElementById('health-uptime');
|
||||
if (uptimeEl && data.uptime) {
|
||||
uptimeEl.textContent = window.formatUptime ? window.formatUptime(data.uptime) : `${Math.floor(data.uptime / 1000)}s`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render service status cards
|
||||
function renderServiceCards(data) {
|
||||
const services = [
|
||||
{ key: 'dns', name: 'DNS Service', icon: '🌐' },
|
||||
{ key: 'proxy', name: 'Proxy Service', icon: '🔒' },
|
||||
{ key: 'swarm', name: 'Swarm', icon: '🔗' },
|
||||
{ key: 'corestore', name: 'Corestore', icon: '💾' }
|
||||
];
|
||||
|
||||
const container = document.getElementById('health-services');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = services.map(service => {
|
||||
const serviceData = data.services?.[service.key] || data.dependencies?.[service.key];
|
||||
const healthy = serviceData?.healthy !== false;
|
||||
const enabled = serviceData?.enabled !== false;
|
||||
const statusColor = healthy ? 'green' : 'red';
|
||||
const statusText = healthy ? 'Healthy' : 'Unhealthy';
|
||||
|
||||
return `
|
||||
<div class="rounded-lg shadow-md p-4 theme-card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl">${service.icon}</span>
|
||||
<h3 class="text-lg font-semibold">${service.name}</h3>
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
healthy ? 'bg-green-500' :
|
||||
'bg-red-500'
|
||||
}" style="color: var(--text-primary);">
|
||||
${statusText}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
${enabled ? 'Enabled' : 'Disabled'}
|
||||
${serviceData?.details ? ` • ${JSON.stringify(serviceData.details).replace(/[{}"]/g, '').substring(0, 50)}...` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Update health history
|
||||
function updateHealthHistory(data) {
|
||||
// Health history tracking removed - chart no longer displayed
|
||||
}
|
||||
|
||||
// Render health history chart - removed, chart no longer displayed
|
||||
function renderHealthHistoryChart() {
|
||||
// Health history chart removed from stats page
|
||||
}
|
||||
|
||||
// Start health updates
|
||||
function startHealthUpdates() {
|
||||
if (healthUpdateInterval) return;
|
||||
|
||||
// Initial render
|
||||
renderHealth();
|
||||
|
||||
// Update every 5 seconds
|
||||
healthUpdateInterval = setInterval(() => {
|
||||
renderHealth();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Stop health updates
|
||||
function stopHealthUpdates() {
|
||||
if (healthUpdateInterval) {
|
||||
clearInterval(healthUpdateInterval);
|
||||
healthUpdateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.renderHealth = renderHealth;
|
||||
window.startHealthUpdates = startHealthUpdates;
|
||||
window.stopHealthUpdates = stopHealthUpdates;
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
// Holesail UI functions - Create server/client modals and actions
|
||||
|
||||
// Open create Holesail server modal
|
||||
function openCreateHolesailModal() {
|
||||
const nameEl = document.getElementById('holesail-name');
|
||||
const portEl = document.getElementById('holesail-port');
|
||||
const hostEl = document.getElementById('holesail-host');
|
||||
const keyEl = document.getElementById('holesail-key');
|
||||
const domainEl = document.getElementById('holesail-domain');
|
||||
const secureEl = document.getElementById('holesail-secure');
|
||||
const udpEl = document.getElementById('holesail-udp');
|
||||
const logEl = document.getElementById('holesail-log');
|
||||
|
||||
if (nameEl) nameEl.value = '';
|
||||
if (portEl) portEl.value = '';
|
||||
if (hostEl) hostEl.value = '127.0.0.1';
|
||||
if (keyEl) keyEl.value = '';
|
||||
if (domainEl) domainEl.value = '';
|
||||
if (secureEl) secureEl.checked = true;
|
||||
if (udpEl) udpEl.checked = false;
|
||||
if (logEl) logEl.value = '1';
|
||||
|
||||
const modal = document.getElementById('createHolesailModal');
|
||||
if (modal) modal.showModal();
|
||||
}
|
||||
|
||||
// Submit create Holesail server
|
||||
async function submitCreateHolesail() {
|
||||
const createBtn = document.getElementById('create-holesail-btn');
|
||||
if (!createBtn) return;
|
||||
|
||||
const originalText = createBtn.innerHTML;
|
||||
createBtn.disabled = true;
|
||||
createBtn.innerHTML = 'Creating... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span>';
|
||||
|
||||
const opts = {
|
||||
name: document.getElementById('holesail-name')?.value || '',
|
||||
port: parseInt(document.getElementById('holesail-port')?.value || '0'),
|
||||
host: document.getElementById('holesail-host')?.value || '127.0.0.1',
|
||||
key: document.getElementById('holesail-key')?.value || '',
|
||||
domain: document.getElementById('holesail-domain')?.value || '',
|
||||
secure: document.getElementById('holesail-secure')?.checked || false,
|
||||
udp: document.getElementById('holesail-udp')?.checked || false,
|
||||
log: parseInt(document.getElementById('holesail-log')?.value || '1')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/holesail-create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(opts)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Holesail server created successfully');
|
||||
}
|
||||
|
||||
const modal = document.getElementById('createHolesailModal');
|
||||
if (modal) modal.close();
|
||||
|
||||
if (window.genericFetch && window.activeTab === 'host') {
|
||||
window.genericFetch('host-servers', true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to create Holesail server: ' + err.message, 'error');
|
||||
}
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
createBtn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Open create client modal
|
||||
async function openCreateClientModal() {
|
||||
if (!window.domainsData && window.genericFetch) {
|
||||
await window.genericFetch('domains', false);
|
||||
}
|
||||
|
||||
const select = document.getElementById('client-domain');
|
||||
if (select && window.domainsData) {
|
||||
// Filter to only show domains where user is owner
|
||||
const ownedDomains = window.domainsData.filter(d => d.isOwner === true);
|
||||
if (ownedDomains.length === 0) {
|
||||
select.innerHTML = '<option value="">No owned domains available</option>';
|
||||
if (window.showNotification) {
|
||||
window.showNotification('You must own a domain to create a client', 'error');
|
||||
}
|
||||
} else {
|
||||
select.innerHTML = ownedDomains.map(d => `<option value="${d.domain}">${d.domain}</option>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
const serviceNameEl = document.getElementById('client-service-name');
|
||||
const keyEl = document.getElementById('client-key');
|
||||
const portEl = document.getElementById('client-port');
|
||||
const protocolEl = document.getElementById('client-protocol');
|
||||
|
||||
if (serviceNameEl) serviceNameEl.value = '';
|
||||
if (keyEl) keyEl.value = '';
|
||||
if (portEl) portEl.value = '';
|
||||
if (protocolEl) protocolEl.value = 'tcp';
|
||||
|
||||
const modal = document.getElementById('createClientModal');
|
||||
if (modal) modal.showModal();
|
||||
}
|
||||
|
||||
// Submit create client
|
||||
async function submitCreateClient() {
|
||||
const domainEl = document.getElementById('client-domain');
|
||||
const serviceNameEl = document.getElementById('client-service-name');
|
||||
const keyEl = document.getElementById('client-key');
|
||||
const portEl = document.getElementById('client-port');
|
||||
const protocolEl = document.getElementById('client-protocol');
|
||||
|
||||
const domain = domainEl?.value || '';
|
||||
const serviceName = serviceNameEl?.value || '';
|
||||
const key = keyEl?.value || '';
|
||||
const port = parseInt(portEl?.value || '0');
|
||||
const protocol = protocolEl?.value || 'tcp';
|
||||
|
||||
if (!domain || !serviceName || !key || isNaN(port)) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('All fields are required', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate ownership
|
||||
const domainData = window.domainsData?.find(d => d.domain === domain);
|
||||
if (!domainData || !domainData.isOwner) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('You must own this domain to create a client', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const createBtn = document.getElementById('create-client-btn');
|
||||
if (!createBtn) return;
|
||||
|
||||
const originalText = createBtn.innerHTML;
|
||||
createBtn.disabled = true;
|
||||
createBtn.innerHTML = 'Creating... <span class="inline-block animate-spin rounded-full h-4 w-4 border-t-2 border-white ml-2"></span>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/holesail-client-create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, serviceName, key, port, protocol })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Holesail client created successfully');
|
||||
}
|
||||
|
||||
const modal = document.getElementById('createClientModal');
|
||||
if (modal) modal.close();
|
||||
|
||||
if (window.genericFetch && window.activeTab === 'host') {
|
||||
window.genericFetch('host-clients', true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to create Holesail client: ' + err.message, 'error');
|
||||
}
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
createBtn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Restart Holesail server
|
||||
async function restartHolesailServer(id) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Restart server ${id}?`, async () => {
|
||||
if (window.pendingServerRestarts) {
|
||||
window.pendingServerRestarts.add(id);
|
||||
}
|
||||
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-servers');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/holesail-restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to restart Holesail server: ' + err.message, 'error');
|
||||
}
|
||||
if (window.pendingServerRestarts) {
|
||||
window.pendingServerRestarts.delete(id);
|
||||
}
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-servers');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Holesail server
|
||||
function deleteHolesailServer(id) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Delete server ${id}?`, async () => {
|
||||
if (window.pendingServerDeletions) {
|
||||
window.pendingServerDeletions.add(id);
|
||||
}
|
||||
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-servers');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/holesail-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to delete Holesail server: ' + err.message, 'error');
|
||||
}
|
||||
if (window.pendingServerDeletions) {
|
||||
window.pendingServerDeletions.delete(id);
|
||||
}
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-servers');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Restart Holesail client
|
||||
async function restartHolesailClient(id) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Restart client ${id}?`, async () => {
|
||||
if (window.pendingClientRestarts) {
|
||||
window.pendingClientRestarts.add(id);
|
||||
}
|
||||
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-clients');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/holesail-client-restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to restart Holesail client: ' + err.message, 'error');
|
||||
}
|
||||
if (window.pendingClientRestarts) {
|
||||
window.pendingClientRestarts.delete(id);
|
||||
}
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-clients');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Holesail client
|
||||
function deleteHolesailClient(id) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Delete client ${id}?`, async () => {
|
||||
if (window.pendingClientDeletions) {
|
||||
window.pendingClientDeletions.add(id);
|
||||
}
|
||||
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-clients');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/holesail-client-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to delete Holesail client: ' + err.message, 'error');
|
||||
}
|
||||
if (window.pendingClientDeletions) {
|
||||
window.pendingClientDeletions.delete(id);
|
||||
}
|
||||
if (window.activeTab === 'host' && window.genericRenderPaginated) {
|
||||
window.genericRenderPaginated('host-clients');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Open Holesail log modal
|
||||
function openHolesailLog(id, name) {
|
||||
const titleEl = document.getElementById('holesail-log-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = `Logs for ${name}`;
|
||||
}
|
||||
|
||||
if (!window.holesailTerm) {
|
||||
if (typeof Terminal !== 'undefined' && typeof FitAddon !== 'undefined') {
|
||||
window.holesailTerm = new Terminal();
|
||||
window.holesailFitAddon = new FitAddon.FitAddon();
|
||||
window.holesailTerm.loadAddon(window.holesailFitAddon);
|
||||
} else {
|
||||
console.error('Terminal or FitAddon not loaded');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById('holesail-terminal');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
window.holesailTerm.open(container);
|
||||
window.holesailTerm.reset();
|
||||
|
||||
const buffer = window.holesailLogBuffers?.get(id) || [];
|
||||
if (buffer.length === 0) {
|
||||
window.holesailTerm.writeln('');
|
||||
window.holesailTerm.writeln('No logs available yet.');
|
||||
window.holesailTerm.writeln('Logs will appear here as they are generated.');
|
||||
window.holesailTerm.writeln('');
|
||||
} else {
|
||||
buffer.forEach(line => window.holesailTerm.writeln(line));
|
||||
}
|
||||
|
||||
if (window.holesailFitAddon) {
|
||||
window.holesailFitAddon.fit();
|
||||
}
|
||||
}
|
||||
|
||||
window.currentOpenHolesailId = id;
|
||||
|
||||
const modal = document.getElementById('holesailLogModal');
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
modal.addEventListener('close', () => {
|
||||
window.currentOpenHolesailId = null;
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Service Subscription functions
|
||||
let subscriptionDomainsData = [];
|
||||
let subscriptionList = [];
|
||||
let subscribeAllDomains = [];
|
||||
|
||||
async function openServiceSubscriptionModal() {
|
||||
const modal = document.getElementById('serviceSubscriptionModal');
|
||||
if (!modal) return;
|
||||
|
||||
// Load subscriptions
|
||||
try {
|
||||
const subsResponse = await fetch('/api/service-subscriptions');
|
||||
if (subsResponse.ok) {
|
||||
subscriptionList = await subsResponse.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading subscriptions:', err);
|
||||
subscriptionList = [];
|
||||
}
|
||||
|
||||
// Load subscribe-all domains
|
||||
try {
|
||||
const subscribeAllResponse = await fetch('/api/subscribe-all-domains');
|
||||
if (subscribeAllResponse.ok) {
|
||||
subscribeAllDomains = await subscribeAllResponse.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading subscribe-all domains:', err);
|
||||
subscribeAllDomains = [];
|
||||
}
|
||||
|
||||
// Load domains with services
|
||||
try {
|
||||
const domainsResponse = await fetch('/api/resolved-domains');
|
||||
if (domainsResponse.ok) {
|
||||
const allDomains = await domainsResponse.json();
|
||||
// Fetch services for each domain, but exclude domains the user owns
|
||||
subscriptionDomainsData = [];
|
||||
for (const domain of allDomains) {
|
||||
// Skip domains where the user is the owner (isOwner === true)
|
||||
if (domain.isOwner === true) {
|
||||
continue;
|
||||
}
|
||||
if (domain.hash && domain.hash !== 'none' && domain.hash !== 'internal') {
|
||||
try {
|
||||
const servicesResponse = await fetch(`/api/domain-services?domain=${encodeURIComponent(domain.domain)}`);
|
||||
if (servicesResponse.ok) {
|
||||
const services = await servicesResponse.json();
|
||||
if (services && services.length > 0) {
|
||||
subscriptionDomainsData.push({
|
||||
domain: domain.domain,
|
||||
hash: domain.hash,
|
||||
services: services
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error loading services for ${domain.domain}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading domains:', err);
|
||||
subscriptionDomainsData = [];
|
||||
}
|
||||
|
||||
renderSubscriptionDomains();
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
function filterSubscriptionDomains() {
|
||||
renderSubscriptionDomains();
|
||||
}
|
||||
|
||||
function renderSubscriptionDomains() {
|
||||
const container = document.getElementById('subscription-domains-list');
|
||||
if (!container) return;
|
||||
|
||||
const searchTerm = document.getElementById('subscription-search')?.value.toLowerCase() || '';
|
||||
const filtered = subscriptionDomainsData.filter(d =>
|
||||
d.domain.toLowerCase().includes(searchTerm) ||
|
||||
d.services.some(s => s.name.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400">No remote domains with services found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(domainData => {
|
||||
const servicesHtml = domainData.services.map(service => {
|
||||
const isSubscribed = subscriptionList.some(sub =>
|
||||
sub.domain === domainData.domain && sub.serviceName === service.name
|
||||
);
|
||||
return `
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg mb-2">
|
||||
<div>
|
||||
<div class="font-semibold">${service.name}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Port: ${service.port} | Protocol: ${service.protocol}</div>
|
||||
</div>
|
||||
<button
|
||||
onclick="${isSubscribed ? `unsubscribeFromService('${domainData.domain}', '${service.name}')` : `subscribeToService('${domainData.domain}', '${service.name}', '${service.key}', ${service.port}, '${service.protocol}')`}"
|
||||
class="px-4 py-2 ${isSubscribed ? 'bg-red-500 hover:bg-red-600' : 'bg-primary hover:bg-primary-hover'} text-white rounded"
|
||||
>
|
||||
${isSubscribed ? 'Unsubscribe' : 'Subscribe'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Check if subscribeAll is enabled for this domain
|
||||
const isSubscribeAll = subscribeAllDomains.includes(domainData.domain);
|
||||
|
||||
return `
|
||||
<div class="border border-gray-300 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-lg font-semibold">${domainData.domain}</h4>
|
||||
${isSubscribeAll ? '<span class="px-2 py-1 text-xs bg-green-500 text-white rounded">Auto-Subscribe Enabled</span>' : ''}
|
||||
</div>
|
||||
<button
|
||||
onclick="${isSubscribeAll ? `unsubscribeAllFromDomain('${domainData.domain}')` : `subscribeAllToDomain('${domainData.domain}')`}"
|
||||
class="px-3 py-1 text-sm ${isSubscribeAll ? 'bg-red-500 hover:bg-red-600' : 'bg-green-600 hover:bg-green-700'} text-white rounded"
|
||||
>
|
||||
${isSubscribeAll ? 'Disable Auto-Subscribe' : 'Enable Auto-Subscribe'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
${servicesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function subscribeToService(domain, serviceName, key, port, protocol) {
|
||||
try {
|
||||
const response = await fetch('/api/service-subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, serviceName, key, port, protocol })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Subscribed to ${domain}/${serviceName}`);
|
||||
}
|
||||
|
||||
// Reload subscriptions and re-render
|
||||
const subsResponse = await fetch('/api/service-subscriptions');
|
||||
if (subsResponse.ok) {
|
||||
subscriptionList = await subsResponse.json();
|
||||
}
|
||||
renderSubscriptionDomains();
|
||||
|
||||
if (window.genericFetch && window.activeTab === 'host') {
|
||||
window.genericFetch('host-clients', true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to subscribe: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribeFromService(domain, serviceName) {
|
||||
try {
|
||||
const response = await fetch('/api/service-unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, serviceName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Unsubscribed from ${domain}/${serviceName}`);
|
||||
}
|
||||
|
||||
// Reload subscriptions and re-render
|
||||
const subsResponse = await fetch('/api/service-subscriptions');
|
||||
if (subsResponse.ok) {
|
||||
subscriptionList = await subsResponse.json();
|
||||
}
|
||||
renderSubscriptionDomains();
|
||||
|
||||
if (window.genericFetch && window.activeTab === 'host') {
|
||||
window.genericFetch('host-clients', true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to unsubscribe: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeAllToDomain(domain) {
|
||||
try {
|
||||
const response = await fetch('/api/subscribe-all', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Enabled auto-subscribe for ${domain}`);
|
||||
}
|
||||
|
||||
// Reload subscribe-all domains and re-render
|
||||
const subscribeAllResponse = await fetch('/api/subscribe-all-domains');
|
||||
if (subscribeAllResponse.ok) {
|
||||
subscribeAllDomains = await subscribeAllResponse.json();
|
||||
}
|
||||
|
||||
// Also subscribe to existing services
|
||||
const domainData = subscriptionDomainsData.find(d => d.domain === domain);
|
||||
if (domainData && domainData.services) {
|
||||
for (const service of domainData.services) {
|
||||
if (!subscriptionList.some(sub => sub.domain === domain && sub.serviceName === service.name)) {
|
||||
await subscribeToService(domain, service.name, service.key, service.port, service.protocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload subscriptions
|
||||
const subsResponse = await fetch('/api/service-subscriptions');
|
||||
if (subsResponse.ok) {
|
||||
subscriptionList = await subsResponse.json();
|
||||
}
|
||||
|
||||
renderSubscriptionDomains();
|
||||
|
||||
if (window.genericFetch && window.activeTab === 'host') {
|
||||
window.genericFetch('host-clients', true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to enable auto-subscribe: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribeAllFromDomain(domain) {
|
||||
try {
|
||||
const response = await fetch('/api/unsubscribe-all', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Disabled auto-subscribe for ${domain}`);
|
||||
}
|
||||
|
||||
// Reload subscribe-all domains and re-render
|
||||
const subscribeAllResponse = await fetch('/api/subscribe-all-domains');
|
||||
if (subscribeAllResponse.ok) {
|
||||
subscribeAllDomains = await subscribeAllResponse.json();
|
||||
}
|
||||
|
||||
renderSubscriptionDomains();
|
||||
|
||||
if (window.genericFetch && window.activeTab === 'host') {
|
||||
window.genericFetch('host-clients', true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to disable auto-subscribe: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.openCreateHolesailModal = openCreateHolesailModal;
|
||||
window.submitCreateHolesail = submitCreateHolesail;
|
||||
window.openCreateClientModal = openCreateClientModal;
|
||||
window.submitCreateClient = submitCreateClient;
|
||||
window.restartHolesailServer = restartHolesailServer;
|
||||
window.deleteHolesailServer = deleteHolesailServer;
|
||||
window.restartHolesailClient = restartHolesailClient;
|
||||
window.deleteHolesailClient = deleteHolesailClient;
|
||||
window.openHolesailLog = openHolesailLog;
|
||||
window.openServiceSubscriptionModal = openServiceSubscriptionModal;
|
||||
window.filterSubscriptionDomains = filterSubscriptionDomains;
|
||||
window.subscribeToService = subscribeToService;
|
||||
window.unsubscribeFromService = unsubscribeFromService;
|
||||
window.subscribeAllToDomain = subscribeAllToDomain;
|
||||
window.unsubscribeAllFromDomain = unsubscribeAllFromDomain;
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
// Info Modal System
|
||||
const infoContent = {
|
||||
'domains': {
|
||||
title: 'Domains',
|
||||
description: 'Manage domains in the P2NS network',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Domains tab shows all domains registered in the P2NS network. Domains with 🏠 are your local claims that have been validated by the network.'
|
||||
},
|
||||
{
|
||||
title: 'Local Claims',
|
||||
content: 'Local claims are domains you own and have registered. These are marked with a 🏠 icon. Only local claims can be removed from the system.'
|
||||
},
|
||||
{
|
||||
title: 'Adding Domains',
|
||||
content: 'Click "Add Domain" to register a new domain. You\'ll need to provide the domain name and its corresponding hash.'
|
||||
},
|
||||
{
|
||||
title: 'Removing Domains',
|
||||
content: 'You can only remove domains that you own (local claims). Click the "Remove" button next to a local domain to remove it.'
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
content: 'Use the search box to filter domains by name or hash.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'local-dns': {
|
||||
title: 'Custom Local DNS Records',
|
||||
description: 'Manage custom DNS records served outside of P2NS assignments',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Custom Local DNS Records allow you to define DNS entries that are served independently of the P2NS system. These records take precedence over P2NS assignments for local resolution.'
|
||||
},
|
||||
{
|
||||
title: 'Supported Record Types',
|
||||
content: 'You can create A, AAAA, CNAME, MX, TXT, SRV, SOA, CAA, NS, and PTR records. Each record type has specific fields that need to be filled.'
|
||||
},
|
||||
{
|
||||
title: 'TTL (Time To Live)',
|
||||
content: 'TTL determines how long DNS resolvers should cache the record. Default is 3600 seconds (1 hour).'
|
||||
},
|
||||
{
|
||||
title: 'Managing Records',
|
||||
content: 'Use "Add Record" to create new entries, "Edit" to modify existing ones, and "Delete" to remove records.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'dns-conflicts': {
|
||||
title: 'DNS Conflict Selector',
|
||||
description: 'Choose between P2P and public DNS for conflicting domains',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'When a domain has both P2P and public DNS records, you can choose which one to use for resolution.'
|
||||
},
|
||||
{
|
||||
title: 'P2P Mode',
|
||||
content: 'P2P mode uses the peer-to-peer network resolution, which connects directly to other nodes in the network.'
|
||||
},
|
||||
{
|
||||
title: 'Public Mode',
|
||||
content: 'Public mode uses traditional DNS resolution through public DNS servers.'
|
||||
},
|
||||
{
|
||||
title: 'Switching Modes',
|
||||
content: 'Use the toggle switch to switch between P2P and Public modes for each conflicting domain.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'entries': {
|
||||
title: 'Autopass Entries',
|
||||
description: 'View the P2P network ledger',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Autopass Entries show the raw data from Autopass cores serving the P2P Network. This is the ledger of the system containing all votes and claims.'
|
||||
},
|
||||
{
|
||||
title: 'Ledger Data',
|
||||
content: 'Each entry represents a record in the distributed ledger. The Key-Value pairs show the actual data stored in the network.'
|
||||
},
|
||||
{
|
||||
title: 'Votes and Claims',
|
||||
content: 'The ledger contains voting records and domain claims that have been validated by the network consensus.'
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
content: 'Use the search box to find specific entries by key or value.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'peers': {
|
||||
title: 'Connected Peers',
|
||||
description: 'View and manage peer connections',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Peers tab shows all nodes currently connected to your P2NS instance in the peer-to-peer network.'
|
||||
},
|
||||
{
|
||||
title: 'Peer Connections',
|
||||
content: 'Each peer represents another node in the P2NS network. These connections enable distributed domain resolution and data synchronization.'
|
||||
},
|
||||
{
|
||||
title: 'Network Topology',
|
||||
content: 'The peer list shows the current state of your network connections. Peers are identified by their unique keys.'
|
||||
},
|
||||
{
|
||||
title: 'Connection Status',
|
||||
content: 'The count next to "Connected Peers" shows how many active peer connections you currently have.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'certs': {
|
||||
title: 'Domain Certificates',
|
||||
description: 'Manage SSL/TLS certificates for domains',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Certificates tab manages SSL/TLS certificates for domains in your P2NS network.'
|
||||
},
|
||||
{
|
||||
title: 'Certificate Generation',
|
||||
content: 'Enter a domain name and click "Generate Cert" to create a new certificate. Certificates are automatically signed by the root CA.'
|
||||
},
|
||||
{
|
||||
title: 'Certificate Management',
|
||||
content: 'You can view certificate details, regenerate certificates, or delete them. Regenerating creates a new certificate with updated validity.'
|
||||
},
|
||||
{
|
||||
title: 'Certificate Details',
|
||||
content: 'Click on a certificate name to view its full details including issuer, validity dates, and fingerprint.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'ca-management': {
|
||||
title: 'CA Management',
|
||||
description: 'Manage the Certificate Authority',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'CA Management controls the Root Certificate Authority that signs all domain certificates.'
|
||||
},
|
||||
{
|
||||
title: 'Regenerate Root CA',
|
||||
content: 'Regenerating the Root CA creates a new CA certificate and key. This invalidates all existing certificates, which will need to be regenerated.'
|
||||
},
|
||||
{
|
||||
title: 'Install Root CA',
|
||||
content: 'Installing the Root CA adds it to your system\'s trusted certificate store, allowing browsers to trust certificates signed by this CA.'
|
||||
},
|
||||
{
|
||||
title: 'Security Note',
|
||||
content: 'Only regenerate the CA if necessary, as it will require reinstalling the CA and regenerating all certificates.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'interfaces': {
|
||||
title: 'Virtual Interfaces',
|
||||
description: 'Manage virtual network interfaces',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Virtual Interfaces are network interfaces created for each domain to enable local routing and DNS resolution.'
|
||||
},
|
||||
{
|
||||
title: 'Interface Assignment',
|
||||
content: 'Each domain gets assigned a virtual IP address on a virtual interface. This allows local applications to connect to P2P domains.'
|
||||
},
|
||||
{
|
||||
title: 'Interface List',
|
||||
content: 'The table shows all active virtual interfaces with their associated domains and IP addresses. Use the search box to filter interfaces, and pagination controls to navigate through the list.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'holesail-servers': {
|
||||
title: 'Holesail Servers',
|
||||
description: 'Manage Holesail tunnel servers',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Holesail Servers create tunnels that allow external connections to reach services on your network.'
|
||||
},
|
||||
{
|
||||
title: 'Server Configuration',
|
||||
content: 'Each server listens on a specific port and can be configured with a name, host, key, and protocol (TCP/UDP).'
|
||||
},
|
||||
{
|
||||
title: 'Server Status',
|
||||
content: 'The status column shows whether a server is running, stopped, or in an error state.'
|
||||
},
|
||||
{
|
||||
title: 'Logs',
|
||||
content: 'Click on a server name to view its logs in real-time. This helps with debugging connection issues.'
|
||||
},
|
||||
{
|
||||
title: 'Restart/Delete',
|
||||
content: 'Use "Restart" to restart a server or "Delete" to permanently remove it.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'holesail-clients': {
|
||||
title: 'Holesail Clients',
|
||||
description: 'Manage Holesail tunnel clients',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Holesail Clients create outbound tunnels to connect to external Holesail servers.'
|
||||
},
|
||||
{
|
||||
title: 'Client Configuration',
|
||||
content: 'Each client connects to a domain using a key and port. The client establishes a tunnel to the server.'
|
||||
},
|
||||
{
|
||||
title: 'Client Status',
|
||||
content: 'The status column shows whether a client is running, stopped, or in an error state.'
|
||||
},
|
||||
{
|
||||
title: 'Creating Clients',
|
||||
content: 'Use "Create Client" to add a new client. You\'ll need the domain, key, and port from the server.'
|
||||
},
|
||||
{
|
||||
title: 'Restart/Delete',
|
||||
content: 'Use "Restart" to restart a client or "Delete" to permanently remove it.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'logs': {
|
||||
title: 'System Logs',
|
||||
description: 'View real-time system logs',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Logs tab shows real-time logs from your P2NS instance. Logs are displayed in a terminal interface.'
|
||||
},
|
||||
{
|
||||
title: 'Log Levels',
|
||||
content: 'Logs are color-coded by level: INFO (normal), WARN (yellow), ERROR (red), and DEBUG (gray).'
|
||||
},
|
||||
{
|
||||
title: 'Log Buffer',
|
||||
content: 'The terminal maintains a buffer of the most recent log messages. Older logs are automatically removed to manage memory.'
|
||||
},
|
||||
{
|
||||
title: 'Real-time Updates',
|
||||
content: 'Logs are updated in real-time via WebSocket connections. If the WebSocket is disconnected, the logs will pause until reconnection.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'stats': {
|
||||
title: 'Statistics',
|
||||
description: 'View system and network statistics',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Stats tab provides detailed statistics about your P2NS instance including system performance, network activity, and resource usage.'
|
||||
},
|
||||
{
|
||||
title: 'Real-time Data',
|
||||
content: 'Statistics are updated in real-time. Enable auto-refresh to keep data current.'
|
||||
},
|
||||
{
|
||||
title: 'Historical Data',
|
||||
content: 'Charts show historical trends over time. Use the time range selector to adjust the view window.'
|
||||
},
|
||||
{
|
||||
title: 'Export Data',
|
||||
content: 'Use "Export Data" to download current statistics as JSON for analysis.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'settings': {
|
||||
title: 'Settings',
|
||||
description: 'Configure P2NS system settings',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Settings tab allows you to configure various aspects of your P2NS instance.'
|
||||
},
|
||||
{
|
||||
title: 'Live Reload',
|
||||
content: 'Settings marked with "Live Reload" are applied immediately without requiring a restart.'
|
||||
},
|
||||
{
|
||||
title: 'Restart Required',
|
||||
content: 'Settings marked with "Requires Restart" will only take effect after restarting the P2NS service.'
|
||||
},
|
||||
{
|
||||
title: 'Subnet Configuration',
|
||||
content: 'Manage IP subnet ranges used for virtual interface assignments. Add, edit, or delete subnet configurations.'
|
||||
},
|
||||
{
|
||||
title: 'Saving Settings',
|
||||
content: 'Click "Save Settings" to apply your changes. You\'ll be notified which settings require a restart.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'subnets': {
|
||||
title: 'Subnet Configuration',
|
||||
description: 'Manage IP subnet ranges',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Subnets define IP address ranges used for virtual interface assignments. Each domain gets assigned an IP from the configured subnets.'
|
||||
},
|
||||
{
|
||||
title: 'Adding Subnets',
|
||||
content: 'Click "Add Subnet" to create a new subnet. The system will suggest a non-conflicting subnet automatically.'
|
||||
},
|
||||
{
|
||||
title: 'IP Capacity',
|
||||
content: 'The IP Capacity Estimator shows total available IPs, currently used IPs, and remaining capacity.'
|
||||
},
|
||||
{
|
||||
title: 'Subnet Management',
|
||||
content: 'Use "Edit" to modify subnet settings or "Delete" to remove a subnet. Changes require a restart to fully apply.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'health': {
|
||||
title: 'Health Monitoring',
|
||||
description: 'Monitor system health and service status',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Health tab provides real-time monitoring of all system services including DNS, Proxy, Swarm, and Corestore.'
|
||||
},
|
||||
{
|
||||
title: 'Service Status',
|
||||
content: 'Service status cards show the health of each component. Green indicates healthy, red indicates issues.'
|
||||
},
|
||||
{
|
||||
title: 'Health History',
|
||||
content: 'The health history chart shows the status of services over time, helping you identify patterns and issues.'
|
||||
},
|
||||
{
|
||||
title: 'Network Diagnostics',
|
||||
content: 'Use the diagnostic tools below to test DNS resolution, ping connectivity, traceroute paths, and connection tests.'
|
||||
},
|
||||
{
|
||||
title: 'Auto-refresh',
|
||||
content: 'Enable auto-refresh to keep health data updated in real-time. Updates occur every 5 seconds.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'plugins': {
|
||||
title: 'Plugins',
|
||||
description: 'Manage plugins and their registered actions and settings',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Plugins tab shows all plugins loaded from the plugin-sites/ directory. Each plugin can register actions (executable functions) and settings (configurable values) that appear in the admin panel.'
|
||||
},
|
||||
{
|
||||
title: 'Plugin Cards',
|
||||
content: 'Each plugin is displayed in a card showing its name, version, description, and status. Plugins can have handlers (dynamic request processing), web UIs (www/ directory), and databases (HyperDB integration).'
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
content: 'Plugins can register actions that can be executed from the admin panel. Click an action button to execute it. Actions can perform operations like resets, statistics gathering, or other plugin-specific functions.'
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
content: 'Plugins can register settings that appear as form fields in the admin panel. Configure plugin behavior by modifying these settings and clicking "Save Settings". Settings support various types: strings, numbers, booleans, selects, and textareas.'
|
||||
},
|
||||
{
|
||||
title: 'Live Reload',
|
||||
content: 'Click the "Restart" button on any plugin card to reload that plugin without restarting P2NS. This is useful for testing plugin changes during development. The plugin will be shut down, its code reloaded, and re-initialized.'
|
||||
},
|
||||
{
|
||||
title: 'Search',
|
||||
content: 'Use the search box to filter plugins by name, domain, description, author, or version.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'backups': {
|
||||
title: 'Backup & Restore',
|
||||
description: 'Manage system backups and restore from previous states',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'The Backups tab allows you to create, restore, and manage backups of your P2NS configuration and data.'
|
||||
},
|
||||
{
|
||||
title: 'Creating Backups',
|
||||
content: 'Click "Create Backup" to manually create a backup. Backups include domains, local DNS records, and selector cache.'
|
||||
},
|
||||
{
|
||||
title: 'Automatic Backups',
|
||||
content: 'The system automatically creates backups at regular intervals (configurable in settings). Old backups are automatically cleaned up based on retention settings.'
|
||||
},
|
||||
{
|
||||
title: 'Restoring Backups',
|
||||
content: 'Click "Restore" on any backup to restore your system to that state. A new backup is automatically created before restoration for safety.'
|
||||
},
|
||||
{
|
||||
title: 'Backup Details',
|
||||
content: 'Click "Details" to view backup metadata including timestamp, version, files included, and their sizes.'
|
||||
},
|
||||
{
|
||||
title: 'Deleting Backups',
|
||||
content: 'Use "Delete" to remove old backups and free up disk space. Deleted backups cannot be recovered.'
|
||||
}
|
||||
]
|
||||
},
|
||||
'diagnostics': {
|
||||
title: 'Network Diagnostics',
|
||||
description: 'Test and troubleshoot network connectivity',
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Network Diagnostics provides tools to test DNS resolution, network connectivity, and troubleshoot connection issues.'
|
||||
},
|
||||
{
|
||||
title: 'DNS Lookup',
|
||||
content: 'Test DNS resolution for any domain. Supports multiple record types (A, AAAA, MX, TXT, NS, CNAME, SRV, PTR, SOA).'
|
||||
},
|
||||
{
|
||||
title: 'Ping',
|
||||
content: 'Ping a target domain or IP address to test basic connectivity and measure response times.'
|
||||
},
|
||||
{
|
||||
title: 'Traceroute',
|
||||
content: 'Trace the network path to a target, showing each hop along the route. Useful for diagnosing routing issues.'
|
||||
},
|
||||
{
|
||||
title: 'Connection Test',
|
||||
content: 'Test TCP connectivity to a specific domain and port. Verifies if a service is reachable.'
|
||||
},
|
||||
{
|
||||
title: 'Network Interfaces',
|
||||
content: 'View information about network interfaces on the system, including IP addresses and interface details.'
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Open info modal
|
||||
function openInfoModal(tabId) {
|
||||
const modal = document.getElementById('infoModal');
|
||||
const content = infoContent[tabId];
|
||||
|
||||
if (!content) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Info content not available for this tab', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const titleEl = document.getElementById('info-modal-title');
|
||||
const contentDiv = document.getElementById('info-modal-content');
|
||||
|
||||
if (titleEl) {
|
||||
titleEl.textContent = content.title;
|
||||
}
|
||||
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `
|
||||
<p class="text-lg text-gray-700 dark:text-gray-300 mb-4">${content.description}</p>
|
||||
<div class="space-y-6">
|
||||
${content.sections.map(section => `
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold mb-2 text-gray-900 dark:text-white">${section.title}</h4>
|
||||
<p class="text-gray-700 dark:text-gray-300">${section.content}</p>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (modal) {
|
||||
modal.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.openInfoModal = openInfoModal;
|
||||
window.infoContent = infoContent;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Interfaces UI functions
|
||||
function cleanupInterfaces() {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Cleanup interfaces?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/cleanup-interfaces', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Interfaces cleaned up successfully');
|
||||
if (window.genericFetch) window.genericFetch('interfaces', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to cleanup interfaces:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to cleanup interfaces: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.cleanupInterfaces = cleanupInterfaces;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
// Local DNS UI functions
|
||||
function openLocalDnsModal(editIndex = -1) {
|
||||
const modal = document.getElementById('localDnsModal');
|
||||
const title = document.getElementById('local-dns-title');
|
||||
const nameInput = document.getElementById('local-name');
|
||||
const typeSelect = document.getElementById('local-type');
|
||||
const ttlInput = document.getElementById('local-ttl');
|
||||
const submitBtn = document.getElementById('local-submit');
|
||||
if (!modal || !title || !nameInput || !typeSelect || !ttlInput || !submitBtn) return;
|
||||
|
||||
nameInput.value = '';
|
||||
typeSelect.value = 'A';
|
||||
ttlInput.value = 3600;
|
||||
if (window.updateLocalForm) updateLocalForm();
|
||||
if (editIndex >= 0) {
|
||||
const rec = window.localDnsData?.find(r => r.index === editIndex);
|
||||
if (rec) {
|
||||
nameInput.value = rec.name;
|
||||
typeSelect.value = rec.type;
|
||||
if (window.updateLocalForm) updateLocalForm(rec);
|
||||
ttlInput.value = rec.ttl;
|
||||
title.textContent = 'Edit Local DNS Record';
|
||||
submitBtn.textContent = 'Update';
|
||||
window.editLocalIndex = editIndex;
|
||||
}
|
||||
} else {
|
||||
title.textContent = 'Add Local DNS Record';
|
||||
submitBtn.textContent = 'Add';
|
||||
window.editLocalIndex = -1;
|
||||
}
|
||||
modal.showModal();
|
||||
}
|
||||
|
||||
function updateLocalForm(rec = null) {
|
||||
const typeEl = document.getElementById('local-type');
|
||||
const fields = document.getElementById('local-value-fields');
|
||||
if (!typeEl || !fields) return;
|
||||
const type = typeEl.value;
|
||||
fields.innerHTML = '';
|
||||
let inputHtml = '';
|
||||
|
||||
switch (type) {
|
||||
case 'A':
|
||||
case 'AAAA':
|
||||
inputHtml = `<input id="local-data" placeholder="IP Address" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
case 'CNAME':
|
||||
inputHtml = `<input id="local-data" placeholder="Target Domain" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
case 'TXT':
|
||||
inputHtml = `<input id="local-data" placeholder="Text" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
case 'MX':
|
||||
inputHtml = `<input id="local-preference" type="number" placeholder="Preference" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-exchange" placeholder="Exchange" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
case 'SRV':
|
||||
inputHtml = `<input id="local-priority" type="number" placeholder="Priority" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-weight" type="number" placeholder="Weight" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-port" type="number" placeholder="Port" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-target" placeholder="Target" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
case 'SOA':
|
||||
inputHtml = `<input id="local-mname" placeholder="Primary Name Server" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-rname" placeholder="Responsible Person" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-serial" type="number" placeholder="Serial" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-refresh" type="number" placeholder="Refresh" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-retry" type="number" placeholder="Retry" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-expire" type="number" placeholder="Expire" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-minimum" type="number" placeholder="Minimum TTL" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
case 'CAA':
|
||||
inputHtml = `<input id="local-flags" type="number" placeholder="Flags" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-tag" placeholder="Tag (e.g., issue, issuewild, iodef)" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"><input id="local-value" placeholder="Value" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
case 'NS':
|
||||
case 'PTR':
|
||||
inputHtml = `<input id="local-data" placeholder="Name Server" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
default:
|
||||
inputHtml = `<input id="local-data" placeholder="Record Data" class="w-full p-3 mb-4 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">`;
|
||||
break;
|
||||
}
|
||||
fields.innerHTML = inputHtml;
|
||||
|
||||
if (rec) {
|
||||
switch (type) {
|
||||
case 'MX':
|
||||
const prefEl = document.getElementById('local-preference');
|
||||
const exchEl = document.getElementById('local-exchange');
|
||||
if (prefEl) prefEl.value = rec.preference || '';
|
||||
if (exchEl) exchEl.value = rec.exchange || '';
|
||||
break;
|
||||
case 'SRV':
|
||||
const priEl = document.getElementById('local-priority');
|
||||
const weightEl = document.getElementById('local-weight');
|
||||
const portEl = document.getElementById('local-port');
|
||||
const targetEl = document.getElementById('local-target');
|
||||
if (priEl) priEl.value = rec.priority || '';
|
||||
if (weightEl) weightEl.value = rec.weight || '';
|
||||
if (portEl) portEl.value = rec.port || '';
|
||||
if (targetEl) targetEl.value = rec.target || '';
|
||||
break;
|
||||
case 'SOA':
|
||||
const mnameEl = document.getElementById('local-mname');
|
||||
const rnameEl = document.getElementById('local-rname');
|
||||
const serialEl = document.getElementById('local-serial');
|
||||
const refreshEl = document.getElementById('local-refresh');
|
||||
const retryEl = document.getElementById('local-retry');
|
||||
const expireEl = document.getElementById('local-expire');
|
||||
const minimumEl = document.getElementById('local-minimum');
|
||||
if (mnameEl) mnameEl.value = rec.mname || '';
|
||||
if (rnameEl) rnameEl.value = rec.rname || '';
|
||||
if (serialEl) serialEl.value = rec.serial || '';
|
||||
if (refreshEl) refreshEl.value = rec.refresh || '';
|
||||
if (retryEl) retryEl.value = rec.retry || '';
|
||||
if (expireEl) expireEl.value = rec.expire || '';
|
||||
if (minimumEl) minimumEl.value = rec.minimum || '';
|
||||
break;
|
||||
case 'CAA':
|
||||
const flagsEl = document.getElementById('local-flags');
|
||||
const tagEl = document.getElementById('local-tag');
|
||||
const valueEl = document.getElementById('local-value');
|
||||
if (flagsEl) flagsEl.value = rec.flags || '';
|
||||
if (tagEl) tagEl.value = rec.tag || '';
|
||||
if (valueEl) valueEl.value = rec.value || '';
|
||||
break;
|
||||
default:
|
||||
const dataEl = document.getElementById('local-data');
|
||||
if (dataEl) dataEl.value = rec.data || rec.value || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLocalDns() {
|
||||
const nameEl = document.getElementById('local-name');
|
||||
const typeEl = document.getElementById('local-type');
|
||||
const ttlEl = document.getElementById('local-ttl');
|
||||
if (!nameEl || !typeEl || !ttlEl) return;
|
||||
|
||||
const name = nameEl.value;
|
||||
const type = typeEl.value;
|
||||
const ttl = parseInt(ttlEl.value) || 3600;
|
||||
let record = { name, type, ttl, class: 'IN' };
|
||||
|
||||
switch (type) {
|
||||
case 'MX':
|
||||
const prefEl = document.getElementById('local-preference');
|
||||
const exchEl = document.getElementById('local-exchange');
|
||||
record.preference = parseInt(prefEl?.value) || 10;
|
||||
record.exchange = exchEl?.value || '';
|
||||
break;
|
||||
case 'SRV':
|
||||
record.priority = parseInt(document.getElementById('local-priority')?.value) || 0;
|
||||
record.weight = parseInt(document.getElementById('local-weight')?.value) || 0;
|
||||
record.port = parseInt(document.getElementById('local-port')?.value) || 0;
|
||||
record.target = document.getElementById('local-target')?.value || '';
|
||||
break;
|
||||
case 'SOA':
|
||||
record.mname = document.getElementById('local-mname')?.value || '';
|
||||
record.rname = document.getElementById('local-rname')?.value || '';
|
||||
record.serial = parseInt(document.getElementById('local-serial')?.value) || 0;
|
||||
record.refresh = parseInt(document.getElementById('local-refresh')?.value) || 0;
|
||||
record.retry = parseInt(document.getElementById('local-retry')?.value) || 0;
|
||||
record.expire = parseInt(document.getElementById('local-expire')?.value) || 0;
|
||||
record.minimum = parseInt(document.getElementById('local-minimum')?.value) || 0;
|
||||
break;
|
||||
case 'CAA':
|
||||
record.flags = parseInt(document.getElementById('local-flags')?.value) || 0;
|
||||
record.tag = document.getElementById('local-tag')?.value || '';
|
||||
record.value = document.getElementById('local-value')?.value || '';
|
||||
break;
|
||||
default:
|
||||
record.data = document.getElementById('local-data')?.value || '';
|
||||
break;
|
||||
}
|
||||
|
||||
const isEdit = window.editLocalIndex >= 0;
|
||||
const url = isEdit ? '/api/update-local-dns' : '/api/add-local-dns';
|
||||
const body = isEdit ? JSON.stringify({ index: window.editLocalIndex, record }) : JSON.stringify(record);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification(isEdit ? 'Record updated successfully' : 'Record added successfully');
|
||||
const modal = document.getElementById('localDnsModal');
|
||||
if (modal) modal.close();
|
||||
if (window.genericFetch) window.genericFetch('local-dns', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to submit local DNS record:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to submit record: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function editLocalDns(index) {
|
||||
openLocalDnsModal(index);
|
||||
}
|
||||
|
||||
function deleteLocalDns(index) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Delete this record?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/delete-local-dns', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ index })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Record deleted successfully');
|
||||
if (window.genericFetch) window.genericFetch('local-dns', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete local DNS record:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to delete record: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleVersionPreference(domain, isPublic) {
|
||||
try {
|
||||
const version = isPublic ? 'public' : 'p2p';
|
||||
const response = await fetch('/api/update-version-preference', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain, version })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
if (window.showNotification) window.showNotification(`Version preference for ${domain} set to ${version}`);
|
||||
if (window.genericFetch) window.genericFetch('dns-conflicts', true);
|
||||
} catch (err) {
|
||||
console.error('Failed to update version preference:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to update version preference: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
window.openLocalDnsModal = openLocalDnsModal;
|
||||
window.updateLocalForm = updateLocalForm;
|
||||
window.submitLocalDns = submitLocalDns;
|
||||
window.editLocalDns = editLocalDns;
|
||||
window.deleteLocalDns = deleteLocalDns;
|
||||
window.toggleVersionPreference = toggleVersionPreference;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Logs UI functions - terminal rendering
|
||||
function renderLogs() {
|
||||
if (!window.terminalInitialized) {
|
||||
// Check if Terminal is available (from xterm.js CDN)
|
||||
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
|
||||
console.error('Terminal or FitAddon not loaded. Make sure xterm.js scripts are loaded.');
|
||||
return;
|
||||
}
|
||||
window.term = new Terminal();
|
||||
window.fitAddon = new FitAddon.FitAddon();
|
||||
window.term.loadAddon(window.fitAddon);
|
||||
const terminalEl = document.getElementById('terminal');
|
||||
if (terminalEl) {
|
||||
window.term.open(terminalEl);
|
||||
window.terminalInitialized = true;
|
||||
} else {
|
||||
console.error('Terminal element not found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (window.term) {
|
||||
window.term.reset();
|
||||
if (window.logBuffer && window.logBuffer.length > 0) {
|
||||
window.logBuffer.forEach(line => window.term.writeln(line));
|
||||
}
|
||||
if (window.fitAddon) {
|
||||
window.fitAddon.fit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle window resize for terminal
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.activeTab === 'logs' && window.fitAddon) {
|
||||
window.fitAddon.fit();
|
||||
}
|
||||
const holesailLogModal = document.getElementById('holesailLogModal');
|
||||
if (holesailLogModal && holesailLogModal.open && window.holesailFitAddon) {
|
||||
window.holesailFitAddon.fit();
|
||||
}
|
||||
});
|
||||
|
||||
window.renderLogs = renderLogs;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Notification and confirmation dialog functions
|
||||
function showNotification(message, type = 'success') {
|
||||
const container = document.getElementById('notifications');
|
||||
if (!container) return;
|
||||
const notification = document.createElement('div');
|
||||
let bgColor = 'bg-green-500';
|
||||
if (type === 'error') bgColor = 'bg-red-500';
|
||||
else if (type === 'warning') bgColor = 'bg-yellow-500';
|
||||
else if (type === 'info') bgColor = 'bg-blue-500';
|
||||
|
||||
notification.classList.add(
|
||||
'p-4', 'rounded-lg', 'shadow-lg', 'text-white',
|
||||
bgColor,
|
||||
'transition-all', 'duration-300', 'opacity-0', 'transform', 'translate-y-4'
|
||||
);
|
||||
notification.textContent = message;
|
||||
container.appendChild(notification);
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('opacity-0', 'translate-y-4');
|
||||
notification.classList.add('opacity-100', 'translate-y-0');
|
||||
}, 10);
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('opacity-100', 'translate-y-0');
|
||||
notification.classList.add('opacity-0', 'translate-y-4');
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// showConfirm is now provided by confirmation-modal.js
|
||||
// This function is kept for backward compatibility
|
||||
function showConfirm(message, callback, options = {}) {
|
||||
if (window.ConfirmationModal) {
|
||||
return window.ConfirmationModal.show({
|
||||
...options,
|
||||
message,
|
||||
onConfirm: callback
|
||||
});
|
||||
}
|
||||
// Fallback to old behavior if modal not loaded
|
||||
const messageEl = document.getElementById('confirm-message');
|
||||
const yesBtn = document.getElementById('confirm-yes');
|
||||
const noBtn = document.getElementById('confirm-no');
|
||||
const modal = document.getElementById('confirmModal');
|
||||
if (!messageEl || !yesBtn || !noBtn || !modal) return;
|
||||
|
||||
messageEl.textContent = message;
|
||||
modal.showModal();
|
||||
|
||||
const yesHandler = () => {
|
||||
callback();
|
||||
modal.close();
|
||||
yesBtn.removeEventListener('click', yesHandler);
|
||||
noBtn.removeEventListener('click', noHandler);
|
||||
};
|
||||
|
||||
const noHandler = () => {
|
||||
modal.close();
|
||||
yesBtn.removeEventListener('click', yesHandler);
|
||||
noBtn.removeEventListener('click', noHandler);
|
||||
};
|
||||
|
||||
yesBtn.addEventListener('click', yesHandler);
|
||||
noBtn.addEventListener('click', noHandler);
|
||||
}
|
||||
|
||||
window.showNotification = showNotification;
|
||||
window.showConfirm = showConfirm;
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Enhanced Peers UI functions
|
||||
|
||||
let peerChart = null;
|
||||
|
||||
// Render peers list - uses generic pagination system
|
||||
async function renderPeers() {
|
||||
if (window.genericFetch) {
|
||||
await window.genericFetch('peers', true);
|
||||
}
|
||||
renderPeerGraph();
|
||||
}
|
||||
|
||||
// Filter peers - uses generic filter system
|
||||
function filterPeers() {
|
||||
if (window.genericFilter) {
|
||||
window.genericFilter('peers');
|
||||
}
|
||||
}
|
||||
|
||||
// Show peer details modal
|
||||
async function showPeerDetails(peerId) {
|
||||
try {
|
||||
const [peerRes, historyRes] = await Promise.all([
|
||||
fetch(`/api/peers/${encodeURIComponent(peerId)}`),
|
||||
fetch(`/api/peers/${encodeURIComponent(peerId)}/history`)
|
||||
]);
|
||||
|
||||
if (!peerRes.ok || !historyRes.ok) {
|
||||
throw new Error('Failed to fetch peer details');
|
||||
}
|
||||
|
||||
const peer = await peerRes.json();
|
||||
const history = await historyRes.json();
|
||||
|
||||
const modal = document.getElementById('peerDetailsModal');
|
||||
if (!modal) return;
|
||||
|
||||
const content = document.getElementById('peer-details-content');
|
||||
if (!content) return;
|
||||
|
||||
const uptime = peer.uptime ? (window.formatUptime ? window.formatUptime(peer.uptime) : `${Math.floor(peer.uptime / 1000)}s`) : 'N/A';
|
||||
const connectTime = peer.connectTime ? new Date(peer.connectTime).toLocaleString() : 'N/A';
|
||||
const lastSeen = peer.metrics?.lastSeen ? new Date(peer.metrics.lastSeen).toLocaleString() : 'N/A';
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">Peer Information</h4>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
|
||||
<p><strong>ID:</strong> <span class="font-mono text-sm break-all">${peer.id}</span></p>
|
||||
<p><strong>Status:</strong> ${peer.connected ? '<span class="text-green-600">Connected</span>' : '<span class="text-gray-600">Disconnected</span>'}</p>
|
||||
<p><strong>Uptime:</strong> ${uptime}</p>
|
||||
<p><strong>Connected At:</strong> ${connectTime}</p>
|
||||
<p><strong>Blocked:</strong> ${peer.isBlocked ? '<span class="text-red-600">Yes</span>' : '<span class="text-green-600">No</span>'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">Metrics</h4>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded">
|
||||
<p><strong>Total Connections:</strong> ${peer.metrics?.connections || 0}</p>
|
||||
<p><strong>Total Duration:</strong> ${peer.metrics?.totalDuration ? (window.formatDuration ? window.formatDuration(peer.metrics.totalDuration) : `${Math.floor(peer.metrics.totalDuration / 1000)}s`) : '0s'}</p>
|
||||
<p><strong>Average Duration:</strong> ${peer.metrics?.avgDuration ? (window.formatDuration ? window.formatDuration(peer.metrics.avgDuration) : `${Math.floor(peer.metrics.avgDuration / 1000)}s`) : 'N/A'}</p>
|
||||
<p><strong>Last Seen:</strong> ${lastSeen}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold mb-2">Connection History (Last 50)</h4>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 p-3 rounded max-h-64 overflow-y-auto">
|
||||
${history.length === 0
|
||||
? '<p class="text-gray-500">No history available</p>'
|
||||
: history.slice(-50).reverse().map(event => `
|
||||
<div class="mb-2 pb-2 border-b border-gray-300 dark:border-gray-600">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-semibold ${event.type === 'connect' ? 'text-green-600' : 'text-red-600'}">${event.type === 'connect' ? 'Connected' : 'Disconnected'}</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${new Date(event.timestamp).toLocaleString()}</span>
|
||||
</div>
|
||||
${event.duration ? `<div class="text-sm text-gray-600 dark:text-gray-400">Duration: ${window.formatDuration ? window.formatDuration(event.duration) : `${Math.floor(event.duration / 1000)}s`}</div>` : ''}
|
||||
${event.error ? `<div class="text-sm text-red-600">Error: ${event.error}</div>` : ''}
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.showModal();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch peer details:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to load peer details: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Block peer
|
||||
async function blockPeer(peerId) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Block peer ${peerId.substring(0, 16)}...?`, async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/peers/${encodeURIComponent(peerId)}/block`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to block peer');
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Peer blocked successfully');
|
||||
await renderPeers();
|
||||
} catch (err) {
|
||||
console.error('Failed to block peer:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to block peer: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unblock peer
|
||||
async function unblockPeer(peerId) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Unblock peer ${peerId.substring(0, 16)}...?`, async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/peers/${encodeURIComponent(peerId)}/unblock`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to unblock peer');
|
||||
}
|
||||
if (window.showNotification) window.showNotification('Peer unblocked successfully');
|
||||
await renderPeers();
|
||||
} catch (err) {
|
||||
console.error('Failed to unblock peer:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to unblock peer: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Render peer connection graph
|
||||
function renderPeerGraph() {
|
||||
const canvas = document.getElementById('peer-graph-chart');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (peerChart) {
|
||||
peerChart.destroy();
|
||||
}
|
||||
|
||||
// Get peers data from global state
|
||||
const peersData = window.peersData || [];
|
||||
|
||||
// Group peers by connection status over time (simplified - using current data)
|
||||
const connected = peersData.filter(p => p.connected).length;
|
||||
const disconnected = peersData.filter(p => !p.connected).length;
|
||||
const blocked = peersData.filter(p => p.isBlocked).length;
|
||||
|
||||
peerChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Connected', 'Disconnected', 'Blocked'],
|
||||
datasets: [{
|
||||
data: [connected, disconnected, blocked],
|
||||
backgroundColor: [
|
||||
'rgb(34, 197, 94)',
|
||||
'rgb(107, 114, 128)',
|
||||
'rgb(239, 68, 68)'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#f1f5f9' // White text for better readability on dark background
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.renderPeers = renderPeers;
|
||||
window.showPeerDetails = showPeerDetails;
|
||||
window.blockPeer = blockPeer;
|
||||
window.unblockPeer = unblockPeer;
|
||||
window.filterPeers = filterPeers;
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
// Plugins UI functions
|
||||
|
||||
let pluginsData = [];
|
||||
|
||||
// Fetch plugins from API
|
||||
async function fetchPlugins() {
|
||||
try {
|
||||
const res = await fetch('/api/plugins');
|
||||
const data = await res.json();
|
||||
pluginsData = data.plugins || [];
|
||||
return pluginsData;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch plugins:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to load plugins', 'error');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize plugin log buffers and terminals (if not already initialized in state.js)
|
||||
if (!window.pluginLogBuffers) {
|
||||
window.pluginLogBuffers = new Map();
|
||||
}
|
||||
|
||||
if (!window.pluginTerminals) {
|
||||
window.pluginTerminals = new Map();
|
||||
}
|
||||
|
||||
if (!window.pluginFitAddons) {
|
||||
window.pluginFitAddons = new Map();
|
||||
}
|
||||
|
||||
if (!window.pluginResizeObservers) {
|
||||
window.pluginResizeObservers = new Map();
|
||||
}
|
||||
|
||||
// Render plugins
|
||||
async function renderPlugins() {
|
||||
const container = document.getElementById('pluginsContainer');
|
||||
if (!container) return;
|
||||
|
||||
const plugins = await fetchPlugins();
|
||||
pluginsData = plugins;
|
||||
|
||||
if (plugins.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="theme-card p-8 text-center">
|
||||
<p class="theme-text-tertiary text-lg">No plugins found</p>
|
||||
<p class="theme-text-tertiary text-sm mt-2">Plugins are loaded from the plugin-sites/ directory</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort plugins: example.plugin always at the bottom
|
||||
const sortedPlugins = [...plugins].sort((a, b) => {
|
||||
if (a.domain === 'example.plugin') return 1;
|
||||
if (b.domain === 'example.plugin') return -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Update pluginsData to sorted order for filtering
|
||||
pluginsData = sortedPlugins;
|
||||
|
||||
container.innerHTML = sortedPlugins.map(plugin => renderPluginCard(plugin)).join('');
|
||||
|
||||
// Don't initialize terminals on page load - they'll be initialized when logs section is shown
|
||||
|
||||
// Attach event listeners for action buttons
|
||||
sortedPlugins.forEach(plugin => {
|
||||
plugin.actions.forEach(action => {
|
||||
const buttonId = `action-${plugin.domain}-${action.name}`;
|
||||
const button = document.getElementById(buttonId);
|
||||
if (button) {
|
||||
button.addEventListener('click', () => executeAction(plugin.domain, action.name, action));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize terminal for a plugin
|
||||
function initializePluginTerminal(domain) {
|
||||
const terminalEl = document.getElementById(`plugin-terminal-${domain}`);
|
||||
if (!terminalEl) {
|
||||
// Terminal element doesn't exist yet, try again after a short delay
|
||||
setTimeout(() => initializePluginTerminal(domain), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing terminal if any
|
||||
cleanupPluginTerminal(domain);
|
||||
|
||||
// Check if Terminal is available
|
||||
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
|
||||
terminalEl.innerHTML = '<p class="theme-text-tertiary p-2">Terminal not available. Please refresh the page.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const term = new Terminal({
|
||||
fontSize: 12,
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace',
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#ffffff'
|
||||
},
|
||||
rows: 10,
|
||||
cols: 80
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
term.open(terminalEl);
|
||||
|
||||
// Small delay to ensure DOM is ready before fitting
|
||||
setTimeout(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Store terminal first so logs can be written to it immediately
|
||||
if (!window.pluginTerminals) {
|
||||
window.pluginTerminals = new Map();
|
||||
}
|
||||
window.pluginTerminals.set(domain, term);
|
||||
|
||||
// Load existing log buffer if available
|
||||
if (!window.pluginLogBuffers) {
|
||||
window.pluginLogBuffers = new Map();
|
||||
}
|
||||
const buffer = window.pluginLogBuffers.get(domain) || [];
|
||||
if (buffer.length > 0) {
|
||||
buffer.forEach(line => term.writeln(line));
|
||||
} else {
|
||||
term.writeln('No logs yet. Logs will appear here as they are generated.');
|
||||
}
|
||||
|
||||
// Store fitAddon for resize handling
|
||||
if (!window.pluginFitAddons) {
|
||||
window.pluginFitAddons = new Map();
|
||||
}
|
||||
window.pluginFitAddons.set(domain, fitAddon);
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
if (window.pluginFitAddons && window.pluginFitAddons.has(domain)) {
|
||||
const addon = window.pluginFitAddons.get(domain);
|
||||
if (addon) {
|
||||
addon.fit();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore resize errors
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(terminalEl);
|
||||
|
||||
// Store observer for cleanup
|
||||
if (!window.pluginResizeObservers) {
|
||||
window.pluginResizeObservers = new Map();
|
||||
}
|
||||
window.pluginResizeObservers.set(domain, resizeObserver);
|
||||
} catch (err) {
|
||||
console.error(`Error initializing terminal for plugin ${domain}:`, err);
|
||||
terminalEl.innerHTML = `<p class="text-red-400 p-2">Error initializing terminal: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Render a single plugin card
|
||||
function renderPluginCard(plugin) {
|
||||
const actionsHtml = plugin.status === 'stopped'
|
||||
? '<p class="text-sm theme-text-tertiary mt-4">Actions unavailable (plugin stopped)</p>'
|
||||
: plugin.actions.length > 0
|
||||
? `
|
||||
<div class="mt-4">
|
||||
<h4 class="text-sm font-semibold theme-text-primary mb-2">Actions</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${plugin.actions.map(action => `
|
||||
<button
|
||||
id="action-${plugin.domain}-${action.name}"
|
||||
class="px-3 py-1 text-sm rounded transition-colors"
|
||||
style="background: rgba(59, 130, 246, 0.3); border: 1px solid rgba(59, 130, 246, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||
onmouseover="this.style.background='rgba(59, 130, 246, 0.5)'; this.style.borderColor='rgba(59, 130, 246, 0.7)'"
|
||||
onmouseout="this.style.background='rgba(59, 130, 246, 0.3)'; this.style.borderColor='rgba(59, 130, 246, 0.5)'"
|
||||
title="${action.description || action.label}"
|
||||
>
|
||||
${action.icon || '⚡'} ${action.label || action.name}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: '<p class="text-sm theme-text-tertiary mt-4">No actions registered</p>';
|
||||
|
||||
const settingsHtml = plugin.status === 'stopped'
|
||||
? '<p class="text-sm theme-text-tertiary mt-4">Settings unavailable (plugin stopped)</p>'
|
||||
: Object.keys(plugin.settings).length > 0
|
||||
? `
|
||||
<div class="mt-4">
|
||||
<h4 class="text-sm font-semibold theme-text-primary mb-2">Settings</h4>
|
||||
<div class="space-y-2">
|
||||
${Object.entries(plugin.settings).map(([key, setting]) => renderSettingInput(plugin.domain, key, setting)).join('')}
|
||||
</div>
|
||||
<button
|
||||
onclick="savePluginSettings('${plugin.domain}')"
|
||||
class="mt-3 px-4 py-2 text-sm rounded transition-colors"
|
||||
style="background: rgba(34, 197, 94, 0.3); border: 1px solid rgba(34, 197, 94, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||
onmouseover="this.style.background='rgba(34, 197, 94, 0.5)'; this.style.borderColor='rgba(34, 197, 94, 0.7)'"
|
||||
onmouseout="this.style.background='rgba(34, 197, 94, 0.3)'; this.style.borderColor='rgba(34, 197, 94, 0.5)'"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: '<p class="text-sm theme-text-tertiary mt-4">No settings registered</p>';
|
||||
|
||||
const statusBadge = plugin.status === 'loaded'
|
||||
? '<span class="px-2 py-1 bg-green-500 rounded text-xs" style="color: var(--text-primary);">Loaded</span>'
|
||||
: plugin.status === 'stopped'
|
||||
? '<span class="px-2 py-1 bg-red-500 rounded text-xs" style="color: var(--text-primary);">Stopped</span>'
|
||||
: '<span class="px-2 py-1 bg-primary rounded text-xs" style="color: var(--text-primary);">Static</span>';
|
||||
|
||||
const featuresHtml = [
|
||||
plugin.hasHandler ? '<span class="text-xs bg-blue-500 px-2 py-1 rounded" style="color: var(--text-primary);">Handler</span>' : '',
|
||||
plugin.hasWww ? '<span class="text-xs bg-purple-500 px-2 py-1 rounded" style="color: var(--text-primary);">Web UI</span>' : '',
|
||||
plugin.hasDatabase ? '<span class="text-xs bg-orange-500 px-2 py-1 rounded" style="color: var(--text-primary);">Database</span>' : ''
|
||||
].filter(Boolean).join('');
|
||||
|
||||
return `
|
||||
<div class="theme-card p-6 plugin-card" data-plugin-domain="${plugin.domain}">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<h3 class="text-xl font-bold theme-text-primary flex items-center gap-2">
|
||||
${plugin.icon ? `<i class="fa-solid fa-${escapeHtml(plugin.icon)}"></i>` : ''}
|
||||
${escapeHtml(plugin.name)}
|
||||
</h3>
|
||||
<div class="ml-2">
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm theme-text-secondary">${escapeHtml(plugin.description || 'No description')}</p>
|
||||
<div class="flex items-center gap-4 mt-2 text-xs theme-text-tertiary">
|
||||
<span>v${escapeHtml(plugin.version)}</span>
|
||||
${plugin.author ? `<span>by ${escapeHtml(plugin.author)}</span>` : ''}
|
||||
</div>
|
||||
${featuresHtml ? `<div class="flex gap-2 mt-2">${featuresHtml}</div>` : ''}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 items-end">
|
||||
<div class="flex items-center gap-2 theme-glass px-3 py-2 rounded-lg">
|
||||
<span class="text-xs font-semibold theme-text-secondary uppercase tracking-wide">Status</span>
|
||||
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? `
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-11 h-6 rounded-full flex items-center justify-end px-1" style="background: rgba(59, 130, 246, 0.3); border: 1px solid rgba(59, 130, 246, 0.5); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1);">
|
||||
<div class="w-5 h-5 rounded-full" style="background: var(--text-primary); border: 1px solid var(--border-color);"></div>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium theme-text-primary min-w-[70px]">
|
||||
<span style="color: var(--success);">Enabled</span>
|
||||
<span class="ml-2 text-xs theme-text-tertiary">(System)</span>
|
||||
</span>
|
||||
</div>
|
||||
` : `
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
${plugin.enabled !== false ? 'checked' : ''}
|
||||
onchange="togglePluginEnabled('${plugin.domain}', this.checked)"
|
||||
id="toggle-${plugin.domain}"
|
||||
>
|
||||
<div class="w-11 h-6 rounded-full peer peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:rounded-full after:h-5 after:w-5 after:transition-all plugin-toggle-switch" style="background: var(--bg-glass); border: 1px solid var(--border-color); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"></div>
|
||||
<span class="ml-3 text-sm font-medium theme-text-primary min-w-[70px]">
|
||||
${plugin.enabled !== false ? '<span style="color: var(--success);">Enabled</span>' : '<span style="color: var(--error);">Disabled</span>'}
|
||||
</span>
|
||||
</label>
|
||||
`)}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
${plugin.status === 'loaded' ? `
|
||||
<button
|
||||
onclick="reloadPlugin('${plugin.domain}')"
|
||||
class="px-4 py-2 rounded transition-colors flex items-center gap-2"
|
||||
style="background: rgba(234, 179, 8, 0.3); border: 1px solid rgba(234, 179, 8, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||
onmouseover="this.style.background='rgba(234, 179, 8, 0.5)'; this.style.borderColor='rgba(234, 179, 8, 0.7)'"
|
||||
onmouseout="this.style.background='rgba(234, 179, 8, 0.3)'; this.style.borderColor='rgba(234, 179, 8, 0.5)'"
|
||||
title="Reload this plugin without restarting P2NS"
|
||||
>
|
||||
🔄 Restart
|
||||
</button>
|
||||
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? '' : `
|
||||
<button
|
||||
onclick="stopPlugin('${plugin.domain}')"
|
||||
class="px-4 py-2 theme-button-info rounded theme-glass-hover transition-colors flex items-center gap-2"
|
||||
title="Stop this plugin (unload it from memory)"
|
||||
>
|
||||
⏹️ Stop
|
||||
</button>
|
||||
`)}
|
||||
` : plugin.enabled !== false ? `
|
||||
<button
|
||||
onclick="startPlugin('${plugin.domain}')"
|
||||
class="px-4 py-2 rounded transition-colors flex items-center gap-2"
|
||||
style="background: rgba(34, 197, 94, 0.3); border: 1px solid rgba(34, 197, 94, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
||||
onmouseover="this.style.background='rgba(34, 197, 94, 0.5)'; this.style.borderColor='rgba(34, 197, 94, 0.7)'"
|
||||
onmouseout="this.style.background='rgba(34, 197, 94, 0.3)'; this.style.borderColor='rgba(34, 197, 94, 0.5)'"
|
||||
title="Start this plugin (load it into memory)"
|
||||
>
|
||||
▶️ Start
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 mt-4" style="border-color: var(--border-color);">
|
||||
${actionsHtml}
|
||||
${settingsHtml}
|
||||
</div>
|
||||
|
||||
${plugin.status === 'stopped' ? `
|
||||
<div class="mt-4 p-3 rounded theme-glass" style="background: rgba(234, 179, 8, 0.2); border: 1px solid rgba(234, 179, 8, 0.4); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);">
|
||||
<p class="text-sm" style="color: var(--text-primary);">
|
||||
⚠️ This plugin is currently stopped. Actions and settings are not available until it is started.
|
||||
</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div id="plugin-logs-${plugin.domain}" class="mt-4 hidden">
|
||||
<h4 class="text-sm font-semibold theme-text-primary mb-2">Logs</h4>
|
||||
<div id="plugin-terminal-${plugin.domain}" class="bg-black rounded-lg overflow-hidden" style="height: 200px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs theme-text-tertiary">
|
||||
<span>Domain: <code class="theme-glass px-1 rounded">${escapeHtml(plugin.domain)}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render a setting input field
|
||||
function renderSettingInput(domain, key, setting) {
|
||||
const inputId = `setting-${domain}-${key}`;
|
||||
// Use saved value if available, otherwise use default
|
||||
const currentValue = setting.value !== undefined ? setting.value : (setting.default !== undefined ? setting.default : '');
|
||||
|
||||
switch (setting.type) {
|
||||
case 'boolean':
|
||||
return `
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="${inputId}"
|
||||
data-plugin-domain="${domain}"
|
||||
data-setting-key="${key}"
|
||||
${currentValue ? 'checked' : ''}
|
||||
class="w-4 h-4 text-primary theme-glass rounded focus:ring-primary"
|
||||
/>
|
||||
<label for="${inputId}" class="text-sm theme-text-primary">
|
||||
${escapeHtml(setting.label || key)}
|
||||
</label>
|
||||
</div>
|
||||
${setting.description ? `<p class="text-xs theme-text-tertiary ml-6">${escapeHtml(setting.description)}</p>` : ''}
|
||||
`;
|
||||
|
||||
case 'number':
|
||||
return `
|
||||
<div>
|
||||
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||
${escapeHtml(setting.label || key)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="${inputId}"
|
||||
data-plugin-domain="${domain}"
|
||||
data-setting-key="${key}"
|
||||
value="${currentValue}"
|
||||
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'select':
|
||||
const optionsHtml = (setting.options || []).map(opt => {
|
||||
const value = typeof opt === 'object' ? opt.value : opt;
|
||||
const label = typeof opt === 'object' ? opt.label : opt;
|
||||
return `<option value="${escapeHtml(value)}" ${value === currentValue ? 'selected' : ''}>${escapeHtml(label)}</option>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div>
|
||||
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||
${escapeHtml(setting.label || key)}
|
||||
</label>
|
||||
<select
|
||||
id="${inputId}"
|
||||
data-plugin-domain="${domain}"
|
||||
data-setting-key="${key}"
|
||||
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
${optionsHtml}
|
||||
</select>
|
||||
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
case 'textarea':
|
||||
return `
|
||||
<div>
|
||||
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||
${escapeHtml(setting.label || key)}
|
||||
</label>
|
||||
<textarea
|
||||
id="${inputId}"
|
||||
data-plugin-domain="${domain}"
|
||||
data-setting-key="${key}"
|
||||
rows="3"
|
||||
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>${escapeHtml(currentValue)}</textarea>
|
||||
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
default: // string
|
||||
return `
|
||||
<div>
|
||||
<label for="${inputId}" class="block text-sm theme-text-primary mb-1">
|
||||
${escapeHtml(setting.label || key)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="${inputId}"
|
||||
data-plugin-domain="${domain}"
|
||||
data-setting-key="${key}"
|
||||
value="${escapeHtml(currentValue)}"
|
||||
class="w-full p-2 theme-input rounded focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
${setting.description ? `<p class="text-xs theme-text-tertiary mt-1">${escapeHtml(setting.description)}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute a plugin action
|
||||
async function executeAction(domain, actionName, action) {
|
||||
if (!action) {
|
||||
const plugin = pluginsData.find(p => p.domain === domain);
|
||||
action = plugin?.actions?.find(a => a.name === actionName);
|
||||
}
|
||||
|
||||
if (!action) {
|
||||
if (window.showNotification) window.showNotification('Action not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buttonId = `action-${domain}-${actionName}`;
|
||||
const button = document.getElementById(buttonId);
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = '⏳ Executing...';
|
||||
}
|
||||
|
||||
// Collect parameters if any
|
||||
const params = {};
|
||||
if (action.params && action.params.length > 0) {
|
||||
// TODO: Show modal to collect parameters
|
||||
// For now, execute with empty params
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/plugins/${domain}/actions/${actionName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Action "${action.label || actionName}" executed successfully`, 'success');
|
||||
}
|
||||
// Refresh plugins to get updated state
|
||||
await renderPlugins();
|
||||
} else {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(data.error || 'Action execution failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error executing action:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to execute action', 'error');
|
||||
}
|
||||
|
||||
const buttonId = `action-${domain}-${actionName}`;
|
||||
const button = document.getElementById(buttonId);
|
||||
if (button && action) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show logs section for a plugin
|
||||
function showPluginLogs(domain) {
|
||||
const logsSection = document.getElementById(`plugin-logs-${domain}`);
|
||||
if (logsSection) {
|
||||
logsSection.classList.remove('hidden');
|
||||
// Always re-initialize terminal to ensure it's set up correctly
|
||||
// Use a small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
initializePluginTerminal(domain);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop a plugin
|
||||
async function stopPlugin(domain) {
|
||||
// Prevent stopping system plugins
|
||||
const SYSTEM_PLUGINS = ['p2ns.admin', 'global.profile'];
|
||||
if (SYSTEM_PLUGINS.includes(domain)) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Cannot stop system plugin: ${domain}. This plugin is required by the system.`, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await window.ConfirmationModal.warning(
|
||||
`Stop plugin "${domain}"? This will unload the plugin from memory. You can start it again later.`,
|
||||
{
|
||||
title: 'Stop Plugin',
|
||||
confirmText: 'Stop',
|
||||
cancelText: 'Cancel'
|
||||
}
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show logs section before cleanup
|
||||
showPluginLogs(domain);
|
||||
|
||||
// Clean up terminal for this plugin
|
||||
cleanupPluginTerminal(domain);
|
||||
|
||||
// Re-initialize after cleanup
|
||||
setTimeout(() => {
|
||||
showPluginLogs(domain);
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${domain}/stop`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Plugin "${domain}" stopped successfully`, 'success');
|
||||
}
|
||||
// Refresh plugins list
|
||||
await renderPlugins();
|
||||
// Re-show logs section after refresh (with longer delay to ensure DOM is ready)
|
||||
setTimeout(() => {
|
||||
showPluginLogs(domain);
|
||||
}, 200);
|
||||
} else {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(data.error || 'Failed to stop plugin', 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error stopping plugin:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to stop plugin', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up terminal for a plugin
|
||||
function cleanupPluginTerminal(domain) {
|
||||
try {
|
||||
// Dispose terminal
|
||||
if (window.pluginTerminals && window.pluginTerminals.has(domain)) {
|
||||
const term = window.pluginTerminals.get(domain);
|
||||
if (term) {
|
||||
term.dispose();
|
||||
}
|
||||
window.pluginTerminals.delete(domain);
|
||||
}
|
||||
|
||||
// Disconnect resize observer
|
||||
if (window.pluginResizeObservers && window.pluginResizeObservers.has(domain)) {
|
||||
const observer = window.pluginResizeObservers.get(domain);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
window.pluginResizeObservers.delete(domain);
|
||||
}
|
||||
|
||||
// Clean up fitAddon
|
||||
if (window.pluginFitAddons && window.pluginFitAddons.has(domain)) {
|
||||
window.pluginFitAddons.delete(domain);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
// Start a plugin
|
||||
async function startPlugin(domain) {
|
||||
// Show logs section
|
||||
showPluginLogs(domain);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${domain}/start`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Plugin "${domain}" started successfully`, 'success');
|
||||
}
|
||||
// Refresh plugins list
|
||||
await renderPlugins();
|
||||
// Re-show logs section after refresh (with longer delay to ensure DOM is ready)
|
||||
setTimeout(() => {
|
||||
showPluginLogs(domain);
|
||||
}, 200);
|
||||
} else {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(data.error || 'Failed to start plugin', 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error starting plugin:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to start plugin', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle plugin enabled/disabled state
|
||||
async function togglePluginEnabled(domain, enabled) {
|
||||
// Prevent toggling system plugins
|
||||
const SYSTEM_PLUGINS = ['p2ns.admin', 'global.profile'];
|
||||
if (SYSTEM_PLUGINS.includes(domain) && !enabled) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Cannot disable system plugin: ${domain}. This plugin is required by the system.`, 'error');
|
||||
}
|
||||
// Refresh to reset toggle state
|
||||
await renderPlugins();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${domain}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ enabled })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Plugin "${domain}" ${enabled ? 'enabled' : 'disabled'} successfully`, 'success');
|
||||
}
|
||||
// Refresh plugins list
|
||||
await renderPlugins();
|
||||
} else {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(data.error || 'Failed to toggle plugin', 'error');
|
||||
}
|
||||
// Refresh to reset toggle state
|
||||
await renderPlugins();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling plugin:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to toggle plugin', 'error');
|
||||
}
|
||||
// Refresh to reset toggle state
|
||||
await renderPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload a plugin
|
||||
async function reloadPlugin(domain) {
|
||||
const confirmed = await window.ConfirmationModal.warning(
|
||||
`Reload plugin "${domain}"? This will restart the plugin without restarting P2NS.`,
|
||||
{
|
||||
title: 'Reload Plugin',
|
||||
confirmText: 'Reload',
|
||||
cancelText: 'Cancel'
|
||||
}
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show logs section
|
||||
showPluginLogs(domain);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${domain}/reload`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Plugin "${domain}" reloaded successfully`, 'success');
|
||||
}
|
||||
// Refresh plugins list
|
||||
await renderPlugins();
|
||||
// Re-show logs section after refresh (with longer delay to ensure DOM is ready)
|
||||
setTimeout(() => {
|
||||
showPluginLogs(domain);
|
||||
}, 200);
|
||||
} else {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(data.error || 'Failed to reload plugin', 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reloading plugin:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to reload plugin', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save plugin settings
|
||||
async function savePluginSettings(domain) {
|
||||
try {
|
||||
const settings = {};
|
||||
const inputs = document.querySelectorAll(`[data-plugin-domain="${domain}"][data-setting-key]`);
|
||||
|
||||
inputs.forEach(input => {
|
||||
const key = input.dataset.settingKey;
|
||||
let value;
|
||||
|
||||
if (input.type === 'checkbox') {
|
||||
value = input.checked;
|
||||
} else if (input.type === 'number') {
|
||||
value = parseFloat(input.value);
|
||||
} else {
|
||||
value = input.value;
|
||||
}
|
||||
|
||||
settings[key] = value;
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/plugins/${domain}/settings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Settings saved for plugin "${domain}"`, 'success');
|
||||
}
|
||||
} else {
|
||||
if (window.showNotification) {
|
||||
window.showNotification(data.error || 'Failed to save settings', 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving plugin settings:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to save settings', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter plugins
|
||||
function filterPlugins() {
|
||||
const query = document.getElementById('search-plugins')?.value.toLowerCase() || '';
|
||||
const cards = document.querySelectorAll('.plugin-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
const domain = card.dataset.pluginDomain;
|
||||
const plugin = pluginsData.find(p => p.domain === domain);
|
||||
|
||||
if (!plugin) {
|
||||
card.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const searchableText = [
|
||||
plugin.name,
|
||||
plugin.domain,
|
||||
plugin.description,
|
||||
plugin.version,
|
||||
plugin.author
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
if (searchableText.includes(query)) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Make functions globally available
|
||||
window.renderPlugins = renderPlugins;
|
||||
window.executeAction = executeAction;
|
||||
window.stopPlugin = stopPlugin;
|
||||
window.startPlugin = startPlugin;
|
||||
window.reloadPlugin = reloadPlugin;
|
||||
window.savePluginSettings = savePluginSettings;
|
||||
window.filterPlugins = filterPlugins;
|
||||
window.initializePluginTerminal = initializePluginTerminal;
|
||||
window.cleanupPluginTerminal = cleanupPluginTerminal;
|
||||
window.showPluginLogs = showPluginLogs;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
// Global state variables
|
||||
window.activeTab = 'domains';
|
||||
window.ws = null;
|
||||
window.wsConnected = false;
|
||||
window.wsReconnectAttempts = 0;
|
||||
window.wsPollingInterval = null;
|
||||
window.statusUpdateInterval = null;
|
||||
window.logBuffer = [];
|
||||
window.maxLogLines = 1000;
|
||||
window.terminalInitialized = false;
|
||||
window.holesailLogBuffers = new Map();
|
||||
window.pluginLogBuffers = new Map();
|
||||
window.pluginTerminals = new Map();
|
||||
window.pluginFitAddons = new Map();
|
||||
window.pluginResizeObservers = new Map();
|
||||
window.term = null;
|
||||
window.fitAddon = null;
|
||||
window.holesailLogBuffers = new Map();
|
||||
window.currentOpenHolesailId = null;
|
||||
window.holesailTerm = null;
|
||||
window.holesailFitAddon = null;
|
||||
window.pendingClientRestarts = new Set();
|
||||
window.pendingServerRestarts = new Set();
|
||||
window.pendingClientDeletions = new Set();
|
||||
window.pendingServerDeletions = new Set();
|
||||
|
||||
// Stats charts
|
||||
window.statsCharts = {};
|
||||
window.statsUpdateInterval = null;
|
||||
window.statsData = null;
|
||||
window.historicalData = null;
|
||||
|
||||
// Local DNS editing state
|
||||
window.editLocalIndex = -1;
|
||||
|
||||
// Settings state
|
||||
window.currentSubnets = [];
|
||||
window.editingSubnetIndex = null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
// System functions - reset, etc.
|
||||
|
||||
// Reset system
|
||||
function resetSystem() {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Reset the system? This will clear storage and restart internally.', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/reset-system', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
if (window.showNotification) {
|
||||
window.showNotification('System reset successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to reset system: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally accessible
|
||||
window.resetSystem = resetSystem;
|
||||
|
||||
Reference in New Issue
Block a user