Full Redesign of p2ns.admin
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// Sync admin bell alerts from server + client-only conditions
|
||||
|
||||
let alertsRefreshTimer = null;
|
||||
let alertsRefreshInFlight = false;
|
||||
|
||||
function mapServerAlert(alert) {
|
||||
return {
|
||||
id: alert.id,
|
||||
severity: alert.severity || 'warning',
|
||||
source: alert.source || 'System',
|
||||
tab: alert.tab,
|
||||
subTab: alert.subTab,
|
||||
title: alert.title,
|
||||
message: alert.message,
|
||||
details: alert.details || [],
|
||||
action: alert.action,
|
||||
actionLabel: alert.action?.type === 'remove-orphan-ip' ? 'Remove' : alert.actionLabel
|
||||
};
|
||||
}
|
||||
|
||||
function syncClientAdminAlerts() {
|
||||
if (!window.setAdminAlert || !window.clearAdminAlert) return;
|
||||
|
||||
if (!window.wsConnected) {
|
||||
window.setAdminAlert('client-ws-disconnected', {
|
||||
severity: 'info',
|
||||
source: 'Admin',
|
||||
title: 'Live updates unavailable',
|
||||
message: 'WebSocket is disconnected. The admin panel is using HTTP polling and data may lag behind.'
|
||||
});
|
||||
} else {
|
||||
window.clearAdminAlert('client-ws-disconnected');
|
||||
}
|
||||
|
||||
const pendingRestart = window.pendingRestartSettings;
|
||||
if (pendingRestart?.length) {
|
||||
window.setAdminAlert('client-settings-restart', {
|
||||
severity: 'warning',
|
||||
source: 'Settings',
|
||||
tab: 'settings',
|
||||
title: 'Restart required',
|
||||
message: 'Some saved settings need a process restart before they take full effect.',
|
||||
details: pendingRestart.slice(0, 8)
|
||||
});
|
||||
} else {
|
||||
window.clearAdminAlert('client-settings-restart');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAdminAlerts() {
|
||||
if (!window.replaceServerAdminAlerts || alertsRefreshInFlight) return;
|
||||
alertsRefreshInFlight = true;
|
||||
try {
|
||||
const response = await fetch('/api/admin/alerts');
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
const data = await response.json();
|
||||
window.replaceServerAdminAlerts((data.alerts || []).map(mapServerAlert));
|
||||
syncClientAdminAlerts();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh admin alerts:', err);
|
||||
syncClientAdminAlerts();
|
||||
} finally {
|
||||
alertsRefreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAdminAlertsRefresh() {
|
||||
refreshAdminAlerts();
|
||||
}
|
||||
|
||||
function startAdminAlertsRefresh() {
|
||||
if (alertsRefreshTimer) return;
|
||||
refreshAdminAlerts();
|
||||
alertsRefreshTimer = setInterval(refreshAdminAlerts, 15000);
|
||||
}
|
||||
|
||||
function stopAdminAlertsRefresh() {
|
||||
if (alertsRefreshTimer) {
|
||||
clearInterval(alertsRefreshTimer);
|
||||
alertsRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.refreshAdminAlerts = refreshAdminAlerts;
|
||||
window.scheduleAdminAlertsRefresh = scheduleAdminAlertsRefresh;
|
||||
window.syncClientAdminAlerts = syncClientAdminAlerts;
|
||||
window.startAdminAlertsRefresh = startAdminAlertsRefresh;
|
||||
window.stopAdminAlertsRefresh = stopAdminAlertsRefresh;
|
||||
window.setPendingRestartSettings = function(settings) {
|
||||
window.pendingRestartSettings = Array.isArray(settings) ? settings : [];
|
||||
syncClientAdminAlerts();
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', startAdminAlertsRefresh);
|
||||
} else {
|
||||
startAdminAlertsRefresh();
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Admin alerts — bell icon + dropdown panel
|
||||
|
||||
const adminAlerts = new Map();
|
||||
let alertsPanelOpen = false;
|
||||
|
||||
function escapeAlertText(value) {
|
||||
const esc = window.escapeHtml || ((text) => String(text));
|
||||
return esc(value);
|
||||
}
|
||||
|
||||
function renderAdminAlerts() {
|
||||
const badge = document.getElementById('admin-alerts-badge');
|
||||
const list = document.getElementById('admin-alerts-list');
|
||||
const empty = document.getElementById('admin-alerts-empty');
|
||||
const count = adminAlerts.size;
|
||||
|
||||
if (badge) {
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.classList.toggle('admin-alerts-badge--visible', count > 0);
|
||||
}
|
||||
|
||||
const bell = document.getElementById('admin-alerts-button');
|
||||
if (bell) {
|
||||
bell.classList.toggle('admin-alerts-bell--active', count > 0);
|
||||
bell.setAttribute('aria-label', count > 0 ? `${count} active alert${count === 1 ? '' : 's'}` : 'Alerts');
|
||||
}
|
||||
|
||||
if (!list) return;
|
||||
|
||||
if (count === 0) {
|
||||
list.innerHTML = '';
|
||||
if (empty) empty.classList.add('admin-alerts-empty--visible');
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty) empty.classList.remove('admin-alerts-empty--visible');
|
||||
|
||||
const items = Array.from(adminAlerts.values()).sort((a, b) => {
|
||||
const severityOrder = { error: 0, warning: 1, info: 2 };
|
||||
const sa = severityOrder[a.severity] ?? 3;
|
||||
const sb = severityOrder[b.severity] ?? 3;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
});
|
||||
|
||||
list.innerHTML = items.map(alert => {
|
||||
const severity = alert.severity || 'warning';
|
||||
const icon = severity === 'error'
|
||||
? 'fa-circle-exclamation'
|
||||
: severity === 'info'
|
||||
? 'fa-circle-info'
|
||||
: 'fa-triangle-exclamation';
|
||||
const source = alert.source ? `<span class="admin-alert-source">${escapeAlertText(alert.source)}</span>` : '';
|
||||
const details = Array.isArray(alert.details) && alert.details.length > 0
|
||||
? `<ul class="admin-alert-details">${alert.details.map(item =>
|
||||
`<li><code>${escapeAlertText(item)}</code></li>`
|
||||
).join('')}</ul>`
|
||||
: '';
|
||||
const viewTab = alert.tab
|
||||
? `<button type="button" class="admin-alert-action admin-alert-action--secondary" data-alert-tab="${escapeAlertText(alert.tab)}"${alert.subTab ? ` data-alert-subtab="${escapeAlertText(alert.subTab)}"` : ''}>View</button>`
|
||||
: '';
|
||||
const action = alert.actionLabel || alert.action?.type === 'remove-orphan-ip'
|
||||
? `<button type="button" class="admin-alert-action" data-alert-id="${escapeAlertText(alert.id)}" data-alert-action="primary">${escapeAlertText(alert.actionLabel || 'Remove')}</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<article class="admin-alert admin-alert--${severity}" data-alert-id="${escapeAlertText(alert.id)}">
|
||||
<div class="admin-alert-icon" aria-hidden="true">
|
||||
<i class="fas ${icon}"></i>
|
||||
</div>
|
||||
<div class="admin-alert-body">
|
||||
<div class="admin-alert-head">
|
||||
<h4 class="admin-alert-title">${escapeAlertText(alert.title || 'Alert')}</h4>
|
||||
${source}
|
||||
</div>
|
||||
<p class="admin-alert-message">${alert.message || ''}</p>
|
||||
${details}
|
||||
${(action || viewTab) ? `<div class="admin-alert-actions">${action}${viewTab}</div>` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setAdminAlert(id, alert) {
|
||||
if (!id) return;
|
||||
adminAlerts.set(id, { ...alert, id });
|
||||
renderAdminAlerts();
|
||||
}
|
||||
|
||||
function clearAdminAlertsByPrefix(prefix) {
|
||||
if (!prefix) return;
|
||||
let changed = false;
|
||||
for (const id of adminAlerts.keys()) {
|
||||
if (id.startsWith(prefix)) {
|
||||
adminAlerts.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) renderAdminAlerts();
|
||||
}
|
||||
|
||||
function clearAdminAlert(id) {
|
||||
if (!id) return;
|
||||
if (adminAlerts.delete(id)) {
|
||||
renderAdminAlerts();
|
||||
}
|
||||
}
|
||||
|
||||
function replaceServerAdminAlerts(alerts) {
|
||||
for (const id of [...adminAlerts.keys()]) {
|
||||
if (!id.startsWith('client-')) {
|
||||
adminAlerts.delete(id);
|
||||
}
|
||||
}
|
||||
for (const alert of alerts || []) {
|
||||
if (alert?.id) {
|
||||
adminAlerts.set(alert.id, alert);
|
||||
}
|
||||
}
|
||||
renderAdminAlerts();
|
||||
}
|
||||
|
||||
function toggleAdminAlertsPanel(forceOpen) {
|
||||
const panel = document.getElementById('admin-alerts-panel');
|
||||
const button = document.getElementById('admin-alerts-button');
|
||||
if (!panel || !button) return;
|
||||
|
||||
alertsPanelOpen = typeof forceOpen === 'boolean' ? forceOpen : !alertsPanelOpen;
|
||||
panel.classList.toggle('admin-alerts-panel--open', alertsPanelOpen);
|
||||
button.setAttribute('aria-expanded', alertsPanelOpen ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function closeAdminAlertsPanel() {
|
||||
if (alertsPanelOpen) toggleAdminAlertsPanel(false);
|
||||
}
|
||||
|
||||
function handleAdminAlertClick(event) {
|
||||
const tabBtn = event.target.closest('[data-alert-tab]');
|
||||
if (tabBtn) {
|
||||
const tabId = tabBtn.dataset.alertTab;
|
||||
const subTabId = tabBtn.dataset.alertSubtab;
|
||||
closeAdminAlertsPanel();
|
||||
if (window.navigateToTab) {
|
||||
window.navigateToTab(tabId);
|
||||
} else {
|
||||
location.hash = tabId;
|
||||
}
|
||||
if (subTabId && window.showSubTab) {
|
||||
window.showSubTab(tabId, subTabId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actionBtn = event.target.closest('[data-alert-action]');
|
||||
if (actionBtn) {
|
||||
const alertId = actionBtn.dataset.alertId;
|
||||
const alert = adminAlerts.get(alertId);
|
||||
if (alert?.onAction) {
|
||||
alert.onAction();
|
||||
} else if (alert?.action?.type === 'remove-orphan-ip' && alert.action.ip && window.removeOrphanedIp) {
|
||||
window.removeOrphanedIp(alert.action.ip);
|
||||
} else if (alert?.actionHandler && typeof window[alert.actionHandler] === 'function') {
|
||||
window[alert.actionHandler]();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function initAdminAlerts() {
|
||||
const button = document.getElementById('admin-alerts-button');
|
||||
const panel = document.getElementById('admin-alerts-panel');
|
||||
const list = document.getElementById('admin-alerts-list');
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
toggleAdminAlertsPanel();
|
||||
});
|
||||
}
|
||||
|
||||
if (list) {
|
||||
list.addEventListener('click', handleAdminAlertClick);
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!alertsPanelOpen) return;
|
||||
if (panel?.contains(event.target) || button?.contains(event.target)) return;
|
||||
closeAdminAlertsPanel();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeAdminAlertsPanel();
|
||||
});
|
||||
|
||||
renderAdminAlerts();
|
||||
}
|
||||
|
||||
window.setAdminAlert = setAdminAlert;
|
||||
window.clearAdminAlert = clearAdminAlert;
|
||||
window.clearAdminAlertsByPrefix = clearAdminAlertsByPrefix;
|
||||
window.replaceServerAdminAlerts = replaceServerAdminAlerts;
|
||||
window.toggleAdminAlertsPanel = toggleAdminAlertsPanel;
|
||||
window.closeAdminAlertsPanel = closeAdminAlertsPanel;
|
||||
window.renderAdminAlerts = renderAdminAlerts;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAdminAlerts);
|
||||
} else {
|
||||
initAdminAlerts();
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// Certificates UI functions
|
||||
|
||||
function regenerateCA() {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Regenerate Root CA?', async () => {
|
||||
if (!window.showConfirm) return;
|
||||
window.showConfirm(
|
||||
'Regenerate the root CA? All existing domain certificates will become invalid and must be regenerated.',
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch('/api/regenerate-ca', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
@@ -13,13 +16,16 @@ function regenerateCA() {
|
||||
console.error('Failed to regenerate CA:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to regenerate CA: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'danger', confirmText: 'Regenerate CA' }
|
||||
);
|
||||
}
|
||||
|
||||
function installCA() {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Install Root CA?', async () => {
|
||||
if (!window.showConfirm) return;
|
||||
window.showConfirm(
|
||||
'Install the root CA into your system trust store?',
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch('/api/install-ca', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
@@ -30,14 +36,20 @@ function installCA() {
|
||||
console.error('Failed to install CA:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to install CA: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'info', confirmText: 'Install CA' }
|
||||
);
|
||||
}
|
||||
|
||||
async function generateCert() {
|
||||
const domainEl = document.getElementById('cert-domain');
|
||||
if (!domainEl) return;
|
||||
const domain = domainEl.value;
|
||||
const domain = domainEl.value.trim();
|
||||
if (!domain) {
|
||||
if (window.showNotification) window.showNotification('Enter a domain name', 'warning');
|
||||
domainEl.focus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/generate-cert', {
|
||||
method: 'POST',
|
||||
@@ -47,6 +59,7 @@ async function generateCert() {
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
domainEl.value = '';
|
||||
if (window.showNotification) window.showNotification('Certificate generated successfully');
|
||||
if (window.genericFetch) window.genericFetch('certs', true);
|
||||
} catch (err) {
|
||||
@@ -56,8 +69,10 @@ async function generateCert() {
|
||||
}
|
||||
|
||||
function deleteCert(domain) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Delete certificate for ${domain}?`, async () => {
|
||||
if (!window.showConfirm) return;
|
||||
window.showConfirm(
|
||||
`Delete the certificate for ${domain}?`,
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch('/api/delete-cert', {
|
||||
method: 'POST',
|
||||
@@ -73,13 +88,16 @@ function deleteCert(domain) {
|
||||
console.error('Failed to delete cert:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to delete cert: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'danger', confirmText: 'Delete' }
|
||||
);
|
||||
}
|
||||
|
||||
function regenerateCert(domain) {
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(`Regenerate certificate for ${domain}?`, async () => {
|
||||
if (!window.showConfirm) return;
|
||||
window.showConfirm(
|
||||
`Regenerate the certificate for ${domain}?`,
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch('/api/regenerate-cert', {
|
||||
method: 'POST',
|
||||
@@ -95,8 +113,9 @@ function regenerateCert(domain) {
|
||||
console.error('Failed to regenerate cert:', err);
|
||||
if (window.showNotification) window.showNotification('Failed to regenerate cert: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'warning', confirmText: 'Regenerate' }
|
||||
);
|
||||
}
|
||||
|
||||
async function showCertDetails(domain) {
|
||||
@@ -108,8 +127,10 @@ async function showCertDetails(domain) {
|
||||
const data = await res.text();
|
||||
const formatted = formatCertificate(data);
|
||||
const contentEl = document.getElementById('cert-details-content');
|
||||
const domainEl = document.getElementById('cert-details-domain');
|
||||
const modal = document.getElementById('certDetailsModal');
|
||||
if (contentEl) contentEl.textContent = formatted;
|
||||
if (domainEl) domainEl.textContent = domain;
|
||||
if (modal) modal.showModal();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cert details:', err);
|
||||
@@ -143,4 +164,3 @@ window.regenerateCert = regenerateCert;
|
||||
window.showCertDetails = showCertDetails;
|
||||
window.formatCertificate = formatCertificate;
|
||||
window.copyCertDetails = copyCertDetails;
|
||||
|
||||
|
||||
@@ -45,6 +45,36 @@ function getConsensusStatusBadge(status) {
|
||||
// Make function globally available
|
||||
window.getConsensusStatusBadge = getConsensusStatusBadge;
|
||||
|
||||
function updateCertsListChrome() {
|
||||
const totalCount = (window.certsData || []).length;
|
||||
const emptyEl = document.getElementById('certsEmpty');
|
||||
const endEl = document.getElementById('certsEnd');
|
||||
const listEl = document.getElementById('certsList');
|
||||
const trulyEmpty = totalCount === 0;
|
||||
|
||||
if (emptyEl) {
|
||||
emptyEl.classList.toggle('cert-empty--visible', trulyEmpty);
|
||||
}
|
||||
if (listEl) {
|
||||
listEl.classList.toggle('cert-list--hidden', trulyEmpty);
|
||||
}
|
||||
if (endEl) {
|
||||
endEl.classList.remove('cert-list-end--visible');
|
||||
}
|
||||
}
|
||||
|
||||
function showCertsListEnd() {
|
||||
const totalCount = (window.certsData || []).length;
|
||||
const visibleCount = (window.filteredCerts || window.certsData || []).length;
|
||||
const endEl = document.getElementById('certsEnd');
|
||||
if (endEl && totalCount > 0 && visibleCount > 0) {
|
||||
endEl.classList.add('cert-list-end--visible');
|
||||
}
|
||||
}
|
||||
|
||||
window.updateCertsListChrome = updateCertsListChrome;
|
||||
window.showCertsListEnd = showCertsListEnd;
|
||||
|
||||
window.chartColors = {
|
||||
primary: 'rgb(59, 130, 246)',
|
||||
success: 'rgb(34, 197, 94)',
|
||||
@@ -102,7 +132,7 @@ window.tabs = {
|
||||
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('unknown')}</td>`;
|
||||
}
|
||||
|
||||
tr.innerHTML = `<td class="p-3">${item.domain}${item.isLocal ? '🏠' : ''}</td>
|
||||
tr.innerHTML = `<td class="p-3">${item.domain}${item.isLocal ? ' <span class="domain-local-badge" title="Local claim"><i class="fas fa-house-user" aria-hidden="true"></i></span>' : ''}</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>`;
|
||||
@@ -203,17 +233,35 @@ window.tabs = {
|
||||
filteredKey: 'filteredCerts',
|
||||
containerId: 'certsList',
|
||||
paginationId: 'certsPagination',
|
||||
sentinelId: 'certsScrollSentinel',
|
||||
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>`;
|
||||
li.className = 'cert-row';
|
||||
const esc = window.escapeHtml || ((value) => String(value));
|
||||
const domainAttr = String(cert).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
li.innerHTML = `
|
||||
<div class="cert-row-main">
|
||||
<span class="cert-row-icon" aria-hidden="true"><i class="fas fa-shield-halved"></i></span>
|
||||
<button type="button" class="cert-row-domain" onclick="showCertDetails('${domainAttr}')">${esc(cert)}</button>
|
||||
</div>
|
||||
<div class="cert-row-actions">
|
||||
<button type="button" class="admin-btn admin-btn--secondary admin-btn--sm" onclick="regenerateCert('${domainAttr}')" title="Regenerate certificate">
|
||||
<i class="fas fa-rotate-right" aria-hidden="true"></i><span class="cert-row-action-label">Regenerate</span>
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--danger admin-btn--sm" onclick="deleteCert('${domainAttr}')" title="Delete certificate">
|
||||
<i class="fas fa-trash-can" aria-hidden="true"></i><span class="cert-row-action-label">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return li;
|
||||
},
|
||||
preRender: () => {
|
||||
updateCertsListChrome();
|
||||
},
|
||||
onAllItemsLoaded: () => {
|
||||
showCertsListEnd();
|
||||
}
|
||||
},
|
||||
interfaces: {
|
||||
@@ -223,14 +271,72 @@ window.tabs = {
|
||||
filteredKey: 'filteredInterfaces',
|
||||
containerId: 'interfacesTable',
|
||||
paginationId: 'interfacesPagination',
|
||||
postFetch: (data) => {
|
||||
if (data && Array.isArray(data.interfaces)) {
|
||||
if (window.renderInterfacesSummary) {
|
||||
window.renderInterfacesSummary(data.summary, data.subnets, data.orphanedIps);
|
||||
}
|
||||
return data.interfaces;
|
||||
}
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
sort: (a, b) => a.domain.localeCompare(b.domain, undefined, { sensitivity: 'base' }),
|
||||
filter: (item, query) => item.domain.toLowerCase().includes(query) || item.ip.toLowerCase().includes(query),
|
||||
filter: (item, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
item.domain.toLowerCase().includes(q) ||
|
||||
item.ip.toLowerCase().includes(q) ||
|
||||
(item.subnetName || '').toLowerCase().includes(q) ||
|
||||
(item.type || '').toLowerCase().includes(q) ||
|
||||
(item.configuredOnSystem ? 'configured' : 'missing').includes(q)
|
||||
);
|
||||
},
|
||||
renderItem: (item) => {
|
||||
const esc = window.escapeHtml || ((value) => String(value));
|
||||
const domainAttr = esc(item.domain).replace(/'/g, "\\'");
|
||||
const ipAttr = esc(item.ip).replace(/'/g, "\\'");
|
||||
const typeBadge = item.type === 'internal'
|
||||
? '<span class="iface-badge iface-badge--internal">Internal</span>'
|
||||
: '<span class="iface-badge iface-badge--virtual">Virtual</span>';
|
||||
const subnetLabel = item.subnetName
|
||||
? esc(item.subnetName)
|
||||
: (item.type === 'internal' ? '—' : '<span class="theme-text-tertiary">Unassigned</span>');
|
||||
let statusBadge;
|
||||
if (item.type === 'internal') {
|
||||
statusBadge = '<span class="iface-badge iface-badge--ok">Loopback</span>';
|
||||
} else if (item.configuredOnSystem) {
|
||||
statusBadge = '<span class="iface-badge iface-badge--ok">On system</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="iface-badge iface-badge--warn">Not on OS</span>';
|
||||
}
|
||||
const tunnels = item.activeConnections + item.activeClients;
|
||||
const tunnelLabel = tunnels === 0
|
||||
? '<span class="theme-text-tertiary">—</span>'
|
||||
: `${item.activeConnections} conn${item.activeConnections === 1 ? '' : 's'}${item.activeClients > 0 ? ` · ${item.activeClients} client${item.activeClients === 1 ? '' : 's'}` : ''}`;
|
||||
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>`;
|
||||
tr.className = 'iface-row';
|
||||
tr.innerHTML = `
|
||||
<td class="p-3">
|
||||
<a href="https://${domainAttr}" target="_blank" rel="noopener noreferrer" class="iface-domain-link">${esc(item.domain)}</a>
|
||||
</td>
|
||||
<td class="p-3"><code class="iface-ip">${esc(item.ip)}</code></td>
|
||||
<td class="p-3">${typeBadge}</td>
|
||||
<td class="p-3">${subnetLabel}</td>
|
||||
<td class="p-3">${statusBadge}</td>
|
||||
<td class="p-3">${tunnelLabel}</td>
|
||||
<td class="p-3 text-right">
|
||||
<button type="button" class="admin-btn admin-btn--secondary admin-btn--sm" onclick="copyInterfaceIp('${ipAttr}')" title="Copy IP">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
return tr;
|
||||
},
|
||||
preRender: (visibleCount) => {
|
||||
if (window.updateInterfacesChrome) window.updateInterfacesChrome(visibleCount);
|
||||
},
|
||||
onAllItemsLoaded: () => {
|
||||
if (window.showInterfacesListEnd) window.showInterfacesListEnd();
|
||||
}
|
||||
},
|
||||
'local-dns': {
|
||||
|
||||
@@ -30,28 +30,28 @@ const ConfirmationModal = {
|
||||
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'
|
||||
confirmButtonClass: 'admin-btn--warning'
|
||||
},
|
||||
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'
|
||||
confirmButtonClass: 'admin-btn--danger'
|
||||
},
|
||||
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'
|
||||
confirmButtonClass: 'admin-btn--primary'
|
||||
},
|
||||
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'
|
||||
confirmButtonClass: 'admin-btn--success'
|
||||
},
|
||||
default: {
|
||||
title: 'Confirm Action',
|
||||
@@ -187,23 +187,20 @@ const 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 class="modal-content confirmation-modal-panel ${this.defaults.width} mx-auto">
|
||||
<div class="modal-header p-6 pb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="modal-icon flex-shrink-0"></div>
|
||||
<h3 class="modal-title text-lg font-semibold flex-1"></h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body p-6 pt-0">
|
||||
<div class="modal-message text-sm theme-text-secondary"></div>
|
||||
</div>
|
||||
<div class="modal-footer p-6 pt-4 flex justify-end gap-3">
|
||||
<button data-cancel-btn type="button" class="admin-btn admin-btn--secondary"></button>
|
||||
<button data-confirm-btn type="button" class="admin-btn admin-btn--primary"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return modal;
|
||||
@@ -243,74 +240,19 @@ const ConfirmationModal = {
|
||||
}
|
||||
}
|
||||
|
||||
// Update buttons with glass styling
|
||||
// Update buttons
|
||||
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)';
|
||||
});
|
||||
const confirmVariant = config.confirmButtonClass || 'admin-btn--primary';
|
||||
confirmBtn.className = `admin-btn ${confirmVariant}`;
|
||||
confirmBtn.style.cssText = '';
|
||||
}
|
||||
|
||||
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.className = 'admin-btn admin-btn--secondary';
|
||||
cancelBtn.style.cssText = '';
|
||||
cancelBtn.classList.remove('hidden');
|
||||
} else {
|
||||
cancelBtn.classList.add('hidden');
|
||||
|
||||
@@ -234,6 +234,9 @@ function loadNextBatch(tabId, data) {
|
||||
// Remove sentinel if exists (but keep config sentinel)
|
||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||
if (start >= data.length && data.length > 0 && config.onAllItemsLoaded) {
|
||||
config.onAllItemsLoaded(data.length);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -257,6 +260,9 @@ function loadNextBatch(tabId, data) {
|
||||
// Remove sentinel if exists (but keep config sentinel)
|
||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||
if (config.onAllItemsLoaded) {
|
||||
config.onAllItemsLoaded(data.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -734,7 +734,7 @@ async function cleanDnsPassStorage() {
|
||||
// Restore button
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.innerHTML = '🗑️ Clean & Restart';
|
||||
buttonEl.innerHTML = '<i class="fas fa-trash-can" aria-hidden="true"></i> Clean & Restart';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ let healthData = null;
|
||||
let healthUpdateInterval = null;
|
||||
|
||||
const SERVICE_DEFS = [
|
||||
{ key: 'dns', name: 'DNS Service', icon: '🌐' },
|
||||
{ key: 'proxy', name: 'Proxy Service', icon: '🔒' },
|
||||
{ key: 'swarm', name: 'Swarm', icon: '🔗' },
|
||||
{ key: 'corestore', name: 'Corestore', icon: '💾' }
|
||||
{ key: 'dns', name: 'DNS Service', icon: 'fa-globe' },
|
||||
{ key: 'proxy', name: 'Proxy Service', icon: 'fa-shield-halved' },
|
||||
{ key: 'swarm', name: 'Swarm', icon: 'fa-diagram-project' },
|
||||
{ key: 'corestore', name: 'Corestore', icon: 'fa-hard-drive' }
|
||||
];
|
||||
|
||||
// Fetch health data
|
||||
@@ -121,16 +121,16 @@ function ensureServiceCards(container) {
|
||||
|
||||
container.dataset.initialized = '1';
|
||||
container.innerHTML = SERVICE_DEFS.map((service) => `
|
||||
<div class="rounded-lg shadow-md p-4 theme-card flex flex-col" data-service="${service.key}">
|
||||
<div class="flex items-center justify-between mb-2 gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-2xl shrink-0" aria-hidden="true">${service.icon}</span>
|
||||
<h3 class="text-lg font-semibold truncate">${service.name}</h3>
|
||||
<div class="admin-card theme-card flex flex-col health-service-card" data-service="${service.key}">
|
||||
<div class="flex items-center justify-between mb-3 gap-2">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="health-service-icon" aria-hidden="true"><i class="fas ${service.icon}"></i></span>
|
||||
<h3 class="text-sm font-semibold truncate">${service.name}</h3>
|
||||
</div>
|
||||
<span class="health-service-badge px-3 py-1 rounded-full text-sm font-semibold shrink-0 bg-gray-500" style="color: var(--text-primary);">-</span>
|
||||
<span class="health-service-badge health-service-badge--neutral">—</span>
|
||||
</div>
|
||||
<p class="health-service-enabled text-sm text-gray-600 dark:text-gray-400 mb-2">-</p>
|
||||
<dl class="health-service-details text-xs text-gray-600 dark:text-gray-400 space-y-1 min-h-[3rem] font-mono tabular-nums"></dl>
|
||||
<p class="health-service-enabled text-xs theme-text-tertiary mb-2">—</p>
|
||||
<dl class="health-service-details text-xs theme-text-tertiary space-y-1 min-h-[3rem] tabular-nums"></dl>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
@@ -147,13 +147,10 @@ function updateServiceCardElement(card, serviceData) {
|
||||
if (badge.textContent !== statusText) {
|
||||
badge.textContent = statusText;
|
||||
}
|
||||
const nextClass = `health-service-badge px-3 py-1 rounded-full text-sm font-semibold shrink-0 ${
|
||||
healthy ? 'bg-green-500' : 'bg-red-500'
|
||||
}`;
|
||||
const nextClass = `health-service-badge health-service-badge--${healthy ? 'ok' : 'error'}`;
|
||||
if (badge.className !== nextClass) {
|
||||
badge.className = nextClass;
|
||||
}
|
||||
badge.style.color = 'var(--text-primary)';
|
||||
}
|
||||
|
||||
const enabledEl = card.querySelector('.health-service-enabled');
|
||||
|
||||
@@ -6,11 +6,11 @@ const infoContent = {
|
||||
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.'
|
||||
content: 'The Domains tab shows all domains registered in the P2NS network. Domains marked with a local badge are your 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.'
|
||||
content: 'Local claims are domains you own and have registered. These are marked with a local badge icon. Only local claims can be removed from the system.'
|
||||
},
|
||||
{
|
||||
title: 'Adding Domains',
|
||||
@@ -190,15 +190,23 @@ const infoContent = {
|
||||
sections: [
|
||||
{
|
||||
title: 'Overview',
|
||||
content: 'Virtual Interfaces are network interfaces created for each domain to enable local routing and DNS resolution.'
|
||||
content: 'Virtual interfaces map each domain to a local IP address so applications can reach P2P domains over HTTPS. Internal/plugin domains use 127.0.0.1; P2P domains receive IPs from configured subnets on the loopback interface.'
|
||||
},
|
||||
{
|
||||
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: 'Domain mappings',
|
||||
content: 'The table lists every domain-to-IP assignment with type (Virtual or Internal), subnet, OS configuration status, and active Holesail tunnel counts. Click a domain to open it; use Copy to grab the IP address.'
|
||||
},
|
||||
{
|
||||
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.'
|
||||
title: 'Subnets & capacity',
|
||||
content: 'The sidebar shows subnet utilization (used vs available IPs). Manage subnet ranges from Settings → Subnet Configuration.'
|
||||
},
|
||||
{
|
||||
title: 'Orphaned IPs',
|
||||
content: 'If an IP is configured on the OS but not mapped to any domain, an alert appears in the top bar bell icon. Use Remove on that alert to delete only that orphaned IP. Full Cleanup on the Interfaces tab stops all tunnels and clears every virtual mapping.'
|
||||
},
|
||||
{
|
||||
title: 'Cleanup',
|
||||
content: 'Cleanup stops all Holesail tunnels and client processes, removes virtual IPs from the loopback interface, and clears mappings except internal 127.0.0.1 entries. Use after shutdown issues or to reset networking state.'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,7 +1,238 @@
|
||||
// Interfaces UI functions
|
||||
function cleanupInterfaces() {
|
||||
// Virtual interfaces tab — summary chrome, cleanup, copy IP
|
||||
|
||||
function updateInterfacesChrome(filteredCount) {
|
||||
const totalCount = (window.interfacesData || []).length;
|
||||
const visibleCount = filteredCount != null
|
||||
? filteredCount
|
||||
: (window.filteredInterfaces || window.interfacesData || []).length;
|
||||
const searchEl = document.getElementById('search-interfaces');
|
||||
const hasSearch = searchEl && searchEl.value.trim().length > 0;
|
||||
|
||||
const emptyEl = document.getElementById('interfacesEmpty');
|
||||
const filteredEmptyEl = document.getElementById('interfacesFilteredEmpty');
|
||||
const endEl = document.getElementById('interfacesEnd');
|
||||
const tableEl = document.querySelector('.interfaces-table');
|
||||
const countEl = document.getElementById('interfacesCount');
|
||||
|
||||
const trulyEmpty = totalCount === 0;
|
||||
const filteredToZero = !trulyEmpty && visibleCount === 0 && hasSearch;
|
||||
|
||||
if (emptyEl) {
|
||||
emptyEl.classList.toggle('iface-empty--visible', trulyEmpty);
|
||||
}
|
||||
if (filteredEmptyEl) {
|
||||
filteredEmptyEl.classList.toggle('iface-empty--visible', filteredToZero);
|
||||
}
|
||||
if (tableEl) {
|
||||
tableEl.classList.toggle('interfaces-table--hidden', trulyEmpty || filteredToZero);
|
||||
}
|
||||
if (countEl) {
|
||||
countEl.textContent = hasSearch && visibleCount !== totalCount
|
||||
? `${visibleCount.toLocaleString()} of ${totalCount.toLocaleString()}`
|
||||
: totalCount.toLocaleString();
|
||||
}
|
||||
if (endEl) {
|
||||
endEl.classList.remove('iface-list-end--visible');
|
||||
}
|
||||
}
|
||||
|
||||
function showInterfacesListEnd() {
|
||||
const totalCount = (window.interfacesData || []).length;
|
||||
const visibleCount = (window.filteredInterfaces || window.interfacesData || []).length;
|
||||
const endEl = document.getElementById('interfacesEnd');
|
||||
if (endEl && totalCount > 0 && visibleCount > 0) {
|
||||
endEl.classList.add('iface-list-end--visible');
|
||||
}
|
||||
}
|
||||
|
||||
function resetInterfacesScrollState() {
|
||||
const tabId = 'interfaces';
|
||||
const config = window.tabs?.[tabId];
|
||||
if (!config) return;
|
||||
|
||||
if (window.infiniteScrollState?.[tabId]) {
|
||||
window.infiniteScrollState[tabId].loadedCount = 0;
|
||||
window.infiniteScrollState[tabId].lastQuery = '';
|
||||
if (window.infiniteScrollState[tabId].observer) {
|
||||
window.infiniteScrollState[tabId].observer.disconnect();
|
||||
window.infiniteScrollState[tabId].observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.getElementById(config.containerId);
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyInterfacesSnapshot(data) {
|
||||
if (!data || !Array.isArray(data.interfaces)) return;
|
||||
|
||||
window.interfacesData = data.interfaces;
|
||||
if (window.renderInterfacesSummary) {
|
||||
window.renderInterfacesSummary(data.summary, data.subnets, data.orphanedIps);
|
||||
}
|
||||
|
||||
const config = window.tabs?.interfaces;
|
||||
if (config?.sort) {
|
||||
window.interfacesData.sort(config.sort);
|
||||
}
|
||||
|
||||
if (window.activeTab !== 'interfaces') return;
|
||||
|
||||
resetInterfacesScrollState();
|
||||
if (window.filterInterfaces) {
|
||||
window.filterInterfaces();
|
||||
} else if (window.genericFilter) {
|
||||
window.genericFilter('interfaces');
|
||||
}
|
||||
}
|
||||
|
||||
function renderInterfacesSummary(summary, subnets, orphanedIps) {
|
||||
if (!summary) return;
|
||||
window.interfacesSummary = summary;
|
||||
window.interfacesSubnets = subnets || [];
|
||||
window.interfacesOrphanedIps = orphanedIps || [];
|
||||
|
||||
const statusLine = document.getElementById('interfacesStatusLine');
|
||||
|
||||
const esc = window.escapeHtml || ((value) => String(value));
|
||||
const ifaceLabel = summary.subnetName || 'loopback';
|
||||
const isEnabled = summary.virtualInterfacesEnabled !== false;
|
||||
const enabledText = isEnabled
|
||||
? `Interface <code>${esc(ifaceLabel)}</code> · ${esc(summary.platform)}`
|
||||
: 'Virtual interfaces disabled';
|
||||
|
||||
if (statusLine) {
|
||||
statusLine.innerHTML = enabledText;
|
||||
}
|
||||
|
||||
const setText = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
};
|
||||
|
||||
setText('ifaceStatMappings', summary.totalMappings.toLocaleString());
|
||||
setText('ifaceStatConfigured', isEnabled
|
||||
? `${summary.configuredOnSystem}/${summary.virtualMappings || 0}`
|
||||
: 'N/A');
|
||||
setText('ifaceStatConnections', summary.totalConnections.toLocaleString());
|
||||
setText('ifaceStatClients', summary.totalClients.toLocaleString());
|
||||
|
||||
renderInterfacesSubnets(subnets, summary);
|
||||
}
|
||||
|
||||
function renderInterfacesSubnets(subnets, summary) {
|
||||
const container = document.getElementById('interfacesSubnetsList');
|
||||
if (!container) return;
|
||||
|
||||
if (!subnets || subnets.length === 0) {
|
||||
container.innerHTML = '<p class="iface-subnets-empty theme-text-tertiary">No subnet configuration</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = subnets.map(subnet => {
|
||||
const esc = window.escapeHtml || ((value) => String(value));
|
||||
const usedPct = subnet.available > 0
|
||||
? Math.min(100, Math.round((subnet.used / subnet.available) * 100))
|
||||
: 0;
|
||||
const name = esc(subnet.name || `Subnet ${subnet.index + 1}`);
|
||||
const cidr = subnet.cidr != null ? `/${subnet.cidr}` : '';
|
||||
return `
|
||||
<div class="iface-subnet-card">
|
||||
<div class="iface-subnet-header">
|
||||
<span class="iface-subnet-name">${name}</span>
|
||||
<span class="iface-subnet-cidr theme-text-tertiary">${esc(subnet.base)}${cidr}</span>
|
||||
</div>
|
||||
<div class="iface-subnet-bar" role="presentation">
|
||||
<div class="iface-subnet-bar-fill" style="width: ${usedPct}%"></div>
|
||||
</div>
|
||||
<p class="iface-subnet-meta theme-text-tertiary">
|
||||
${subnet.used} used · ${subnet.remaining} free of ${subnet.available}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function copyInterfaceIp(ip) {
|
||||
const text = String(ip || '');
|
||||
if (!text) return;
|
||||
let ok = false;
|
||||
if (window.sdk?.utils?.dom?.copyToClipboard) {
|
||||
ok = await window.sdk.utils.dom.copyToClipboard(text);
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (window.showNotification) {
|
||||
window.showNotification(ok ? `Copied ${text}` : 'Failed to copy IP', ok ? 'success' : 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOrphanedIp(ip) {
|
||||
const targetIp = String(ip || '').trim();
|
||||
if (!targetIp) return;
|
||||
|
||||
const message = `Remove orphaned IP ${targetIp} from the system? Other domain mappings and tunnels will not be affected.`;
|
||||
const performRemove = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/interfaces/remove-ip', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip: targetIp })
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || response.statusText);
|
||||
}
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Removed orphaned IP ${targetIp}`, 'success');
|
||||
}
|
||||
if (window.clearAdminAlert) {
|
||||
window.clearAdminAlert(`interfaces-orphan-${targetIp}`);
|
||||
}
|
||||
if (window.scheduleAdminAlertsRefresh) {
|
||||
window.scheduleAdminAlertsRefresh();
|
||||
}
|
||||
if (window.genericFetch) {
|
||||
window.genericFetch('interfaces', window.activeTab === 'interfaces');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to remove orphaned IP:', err);
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Failed to remove IP: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm('Cleanup interfaces?', async () => {
|
||||
window.showConfirm(message, performRemove);
|
||||
} else {
|
||||
await performRemove();
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupInterfaces() {
|
||||
const summary = window.interfacesSummary;
|
||||
let message = 'Remove virtual interface IPs from the system and stop active tunnels? Internal (127.0.0.1) mappings are preserved.';
|
||||
if (summary && summary.virtualMappings > 0) {
|
||||
const parts = [`remove ${summary.virtualMappings} virtual IP${summary.virtualMappings === 1 ? '' : 's'}`];
|
||||
if (summary.totalConnections > 0) {
|
||||
parts.push(`stop ${summary.totalConnections} tunnel link${summary.totalConnections === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (summary.totalClients > 0) {
|
||||
parts.push(`terminate ${summary.totalClients} client process${summary.totalClients === 1 ? '' : 'es'}`);
|
||||
}
|
||||
message = `This will ${parts.join(', ')}. Internal (127.0.0.1) mappings are preserved. Continue?`;
|
||||
}
|
||||
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(message, async () => {
|
||||
try {
|
||||
const response = await fetch('/api/cleanup-interfaces', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
@@ -11,11 +242,18 @@ function cleanupInterfaces() {
|
||||
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');
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Failed to cleanup interfaces: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.updateInterfacesChrome = updateInterfacesChrome;
|
||||
window.showInterfacesListEnd = showInterfacesListEnd;
|
||||
window.applyInterfacesSnapshot = applyInterfacesSnapshot;
|
||||
window.renderInterfacesSummary = renderInterfacesSummary;
|
||||
window.copyInterfaceIp = copyInterfaceIp;
|
||||
window.removeOrphanedIp = removeOrphanedIp;
|
||||
window.cleanupInterfaces = cleanupInterfaces;
|
||||
|
||||
|
||||
@@ -191,13 +191,10 @@ function renderPluginCard(plugin) {
|
||||
${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)'"
|
||||
class="admin-btn admin-btn--primary admin-btn--sm"
|
||||
title="${action.description || action.label}"
|
||||
>
|
||||
${action.icon || '⚡'} ${action.label || action.name}
|
||||
${action.icon ? `<i class="fas fa-${escapeHtml(action.icon)}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || action.name}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
@@ -216,10 +213,7 @@ function renderPluginCard(plugin) {
|
||||
</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)'"
|
||||
class="admin-btn admin-btn--success admin-btn--sm mt-3"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
@@ -228,15 +222,15 @@ function renderPluginCard(plugin) {
|
||||
: '<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>'
|
||||
? '<span class="plugin-status-badge plugin-status-badge--loaded">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>';
|
||||
? '<span class="plugin-status-badge plugin-status-badge--stopped">Stopped</span>'
|
||||
: '<span class="plugin-status-badge plugin-status-badge--static">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>' : ''
|
||||
plugin.hasHandler ? '<span class="plugin-feature-tag">Handler</span>' : '',
|
||||
plugin.hasWww ? '<span class="plugin-feature-tag">Web UI</span>' : '',
|
||||
plugin.hasDatabase ? '<span class="plugin-feature-tag">Database</span>' : ''
|
||||
].filter(Boolean).join('');
|
||||
|
||||
const isLoading = pluginLoadingStates.get(plugin.domain) || false;
|
||||
@@ -249,7 +243,7 @@ function renderPluginCard(plugin) {
|
||||
<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)}
|
||||
${isLoading ? '<span class="ml-2 text-sm animate-spin" style="color: var(--primary);">⟳</span>' : ''}
|
||||
${isLoading ? '<span class="ml-2 text-sm"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></span>' : ''}
|
||||
</h3>
|
||||
<div class="ml-2">
|
||||
${statusBadge}
|
||||
@@ -263,15 +257,13 @@ function renderPluginCard(plugin) {
|
||||
${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 plugin-toggle-container ${isLoading ? 'loading' : ''}">
|
||||
<div class="flex items-center gap-2 admin-card px-3 py-2 rounded-lg plugin-toggle-container ${isLoading ? 'loading' : ''}">
|
||||
<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>
|
||||
<div class="plugin-toggle-switch plugin-toggle-switch--on plugin-toggle-switch--locked"></div>
|
||||
<span class="ml-3 text-sm font-medium theme-text-primary min-w-[70px]">
|
||||
<span style="color: var(--success);">Enabled</span>
|
||||
<span class="text-success">Enabled</span>
|
||||
<span class="ml-2 text-xs theme-text-tertiary">(System)</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -285,9 +277,9 @@ function renderPluginCard(plugin) {
|
||||
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 ${isLoading ? 'loading' : ''}" 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>
|
||||
<div class="plugin-toggle-switch peer peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-primary peer-checked:plugin-toggle-switch--on ${isLoading ? 'loading' : ''}"></div>
|
||||
<span class="ml-3 text-sm font-medium theme-text-primary min-w-[70px] plugin-status-text">
|
||||
${isLoading ? '<span style="color: var(--primary);">Loading...</span>' : (plugin.enabled !== false ? '<span style="color: var(--success);">Enabled</span>' : '<span style="color: var(--error);">Disabled</span>')}
|
||||
${isLoading ? '<span class="text-primary">Loading…</span>' : (plugin.enabled !== false ? '<span class="text-success">Enabled</span>' : '<span class="text-error">Disabled</span>')}
|
||||
</span>
|
||||
</label>
|
||||
`)}
|
||||
@@ -296,33 +288,27 @@ function renderPluginCard(plugin) {
|
||||
${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)'"
|
||||
class="admin-btn admin-btn--warning admin-btn--sm"
|
||||
title="Reload this plugin without restarting P2NS"
|
||||
>
|
||||
🔄 Restart
|
||||
<i class="fas fa-rotate-right" aria-hidden="true"></i> 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"
|
||||
class="admin-btn admin-btn--secondary admin-btn--sm"
|
||||
title="Stop this plugin (unload it from memory)"
|
||||
>
|
||||
⏹️ Stop
|
||||
<i class="fas fa-stop" aria-hidden="true"></i> 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)'"
|
||||
class="admin-btn admin-btn--success admin-btn--sm"
|
||||
title="Start this plugin (load it into memory)"
|
||||
>
|
||||
▶️ Start
|
||||
<i class="fas fa-play" aria-hidden="true"></i> Start
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -335,9 +321,9 @@ function renderPluginCard(plugin) {
|
||||
</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.
|
||||
<div class="mt-4 p-3 rounded-lg plugin-alert-banner">
|
||||
<p class="text-sm theme-text-secondary">
|
||||
<i class="fas fa-triangle-exclamation mr-1.5" aria-hidden="true"></i>This plugin is currently stopped. Actions and settings are not available until it is started.
|
||||
</p>
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -566,7 +552,7 @@ async function executeAction(domain, actionName, action) {
|
||||
if (params === null) {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||
button.innerHTML = `${action.icon ? `<i class="fas fa-${action.icon}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || actionName}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -593,7 +579,7 @@ async function executeAction(domain, actionName, action) {
|
||||
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||
button.innerHTML = `${action.icon ? `<i class="fas fa-${action.icon}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || actionName}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error executing action:', err);
|
||||
@@ -605,7 +591,7 @@ async function executeAction(domain, actionName, action) {
|
||||
const button = document.getElementById(buttonId);
|
||||
if (button && action) {
|
||||
button.disabled = false;
|
||||
button.innerHTML = `${action.icon || '⚡'} ${action.label || actionName}`;
|
||||
button.innerHTML = `${action.icon ? `<i class="fas fa-${action.icon}" aria-hidden="true"></i>` : '<i class="fas fa-bolt" aria-hidden="true"></i>'} ${action.label || actionName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,10 +633,16 @@ async function saveSettings(buttonElement) {
|
||||
|
||||
if (result.restartRequired) {
|
||||
const settingsList = result.restartRequiredSettings.join(', ');
|
||||
if (window.setPendingRestartSettings) {
|
||||
window.setPendingRestartSettings(result.restartRequiredSettings || []);
|
||||
}
|
||||
if (window.showNotification) {
|
||||
window.showNotification(`Settings saved. The following settings require restart to take effect: ${settingsList}. Other settings have been applied live.`, 'warning');
|
||||
}
|
||||
} else {
|
||||
if (window.setPendingRestartSettings) {
|
||||
window.setPendingRestartSettings([]);
|
||||
}
|
||||
if (window.showNotification) {
|
||||
window.showNotification('Settings saved and applied successfully (no restart required).', 'success');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user