Full Redesign of p2ns.admin
This commit is contained in:
@@ -12,6 +12,29 @@ const { isSecureHolesailKey } = require('../../infrastructure/utils');
|
||||
|
||||
const holesailClientsFile = process.env.HOLESAIL_CLIENTS_FILE || './cache/holesail_clients.json';
|
||||
|
||||
function getHolesailClientStatus(id, opts, info = {}) {
|
||||
if (info.state === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const child = state.holesailClientChildren.get(id);
|
||||
if (child && !child.killed) {
|
||||
// Admin clients run Holesail in a forked child once ready.
|
||||
return 'running';
|
||||
}
|
||||
|
||||
const key = `${opts.domain}:${opts.port}`;
|
||||
if (state.holesails.has(key)) {
|
||||
return 'running';
|
||||
}
|
||||
|
||||
if (info.state === 'starting') {
|
||||
return 'starting';
|
||||
}
|
||||
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
async function loadHolesailClients() {
|
||||
state.holesailClientChildren = new Map();
|
||||
state.holesailClientOpts = new Map();
|
||||
@@ -279,6 +302,7 @@ async function saveHolesailClients() {
|
||||
module.exports = {
|
||||
loadHolesailClients,
|
||||
startForkedHolesailClient,
|
||||
saveHolesailClients
|
||||
saveHolesailClients,
|
||||
getHolesailClientStatus
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const { logError } = require('../../infrastructure/logger');
|
||||
|
||||
let pendingBroadcast = null;
|
||||
let broadcastFn = null;
|
||||
|
||||
function getBroadcast() {
|
||||
if (!broadcastFn) {
|
||||
({ broadcast: broadcastFn } = require('./websocket'));
|
||||
}
|
||||
return broadcastFn;
|
||||
}
|
||||
|
||||
async function getInterfacesSnapshot() {
|
||||
const { buildInterfacesResponse } = require('../interfaces-data');
|
||||
const payload = await buildInterfacesResponse();
|
||||
return {
|
||||
type: 'interfaces-list',
|
||||
...payload,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
async function broadcastInterfacesList() {
|
||||
try {
|
||||
const { adminClients } = require('./websocket');
|
||||
if (!adminClients || adminClients.size === 0) return;
|
||||
getBroadcast()(await getInterfacesSnapshot());
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to broadcast interfaces list: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleInterfacesBroadcast() {
|
||||
if (pendingBroadcast) return;
|
||||
pendingBroadcast = setTimeout(() => {
|
||||
pendingBroadcast = null;
|
||||
broadcastInterfacesList();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getInterfacesSnapshot,
|
||||
broadcastInterfacesList,
|
||||
scheduleInterfacesBroadcast
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
const { logError } = require('../../../infrastructure/logger');
|
||||
const { collectAdminAlerts } = require('../../alerts-collector');
|
||||
|
||||
async function handleAlertsRoutes(req, res) {
|
||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||
const method = req.method;
|
||||
|
||||
if (method === 'GET' && urlPath === '/api/admin/alerts') {
|
||||
try {
|
||||
const payload = await collectAdminAlerts();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to collect admin alerts: ${err.message}`);
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: 'Failed to collect admin alerts' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = { handleAlertsRoutes };
|
||||
@@ -7,7 +7,7 @@ const { validateHolesailClient } = require('../../../infrastructure/validation')
|
||||
const { createInterfaceForDomain } = require('../../../networking/virtual_interfaces');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../../../infrastructure/logger');
|
||||
const { startHolesailServer, saveHolesailServers } = require('../holesail-servers');
|
||||
const { startForkedHolesailClient, saveHolesailClients } = require('../holesail-clients');
|
||||
const { startForkedHolesailClient, saveHolesailClients, getHolesailClientStatus } = require('../holesail-clients');
|
||||
const { ensurePortFree } = require('../port-management');
|
||||
const { broadcast } = require('../websocket');
|
||||
const { getConsensusState, getClaimClients, updateClaimClients } = require('../../../core/core');
|
||||
@@ -42,19 +42,8 @@ async function handleHolesailRoutes(req, res) {
|
||||
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
||||
try {
|
||||
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||
const child = state.holesailClientChildren.get(id);
|
||||
const info = state.holesailClientInfos.get(id) || {};
|
||||
const key = `${opts.domain}:${opts.port}`;
|
||||
const isHolesailActive = state.holesails.has(key);
|
||||
const isChildRunning = child && !child.killed;
|
||||
let status = 'stopped';
|
||||
if (isChildRunning && isHolesailActive && info.state !== 'error') {
|
||||
status = 'running';
|
||||
} else if (isChildRunning || isHolesailActive) {
|
||||
status = 'starting';
|
||||
} else if (info.state === 'error') {
|
||||
status = 'error';
|
||||
}
|
||||
const status = getHolesailClientStatus(id, opts, info);
|
||||
return { id, opts, info: { ...info, state: status } };
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
|
||||
@@ -16,6 +16,7 @@ const { handleDiagnosticsRoutes } = require('./diagnostics');
|
||||
const { handleConsensusRoutes } = require('./consensus');
|
||||
const { handlePluginsRoutes } = require('./plugins');
|
||||
const { handleLogsRoutes } = require('./logs');
|
||||
const { handleAlertsRoutes } = require('./alerts');
|
||||
|
||||
async function handleAdminRequest(req, res) {
|
||||
const url = new URL(req.url, `https://${req.headers.host}`);
|
||||
@@ -54,9 +55,10 @@ async function handleAdminRequest(req, res) {
|
||||
if (await handleConsensusRoutes(req, res)) return;
|
||||
if (await handlePluginsRoutes(req, res)) return;
|
||||
if (await handleLogsRoutes(req, res)) return;
|
||||
if (await handleAlertsRoutes(req, res)) return;
|
||||
|
||||
// No route matched
|
||||
res.writeHead(404);
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Not Found');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
const state = require('../../../infrastructure/state');
|
||||
const { cleanupInterfaces } = require('../../../maintenance/cleanup');
|
||||
const { logError } = require('../../../infrastructure/logger');
|
||||
const { broadcast } = require('../websocket');
|
||||
const { scheduleInterfacesBroadcast } = require('../interfaces-broadcast');
|
||||
const { buildInterfacesResponse, removeOrphanedInterfaceIp } = require('../../interfaces-data');
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch (err) {
|
||||
reject(new Error('Invalid JSON body'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInterfacesRoutes(req, res) {
|
||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||
@@ -9,9 +24,9 @@ async function handleInterfacesRoutes(req, res) {
|
||||
|
||||
if (method === 'GET' && urlPath === '/api/interfaces') {
|
||||
try {
|
||||
const interfaces = Array.from(state.domainToIPMap.entries()).map(([domain, ip]) => ({ domain, ip }));
|
||||
const payload = await buildInterfacesResponse();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(interfaces));
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
||||
res.writeHead(500);
|
||||
@@ -20,10 +35,25 @@ async function handleInterfacesRoutes(req, res) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === 'POST' && urlPath === '/api/interfaces/remove-ip') {
|
||||
try {
|
||||
const { ip } = await readJsonBody(req);
|
||||
const removedIp = await removeOrphanedInterfaceIp(ip);
|
||||
scheduleInterfacesBroadcast();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, ip: removedIp }));
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to remove orphaned IP: ${err.message}`);
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
|
||||
try {
|
||||
await cleanupInterfaces();
|
||||
broadcast({ type: 'update-interfaces' });
|
||||
scheduleInterfacesBroadcast();
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('OK');
|
||||
} catch (err) {
|
||||
@@ -38,4 +68,3 @@ async function handleInterfacesRoutes(req, res) {
|
||||
}
|
||||
|
||||
module.exports = { handleInterfacesRoutes };
|
||||
|
||||
|
||||
@@ -2,11 +2,65 @@ const fs = require('fs').promises;
|
||||
const pathModule = require('path');
|
||||
const { logDebug, logError } = require('../../../infrastructure/logger');
|
||||
|
||||
const ADMIN_FRONTEND_DIR = pathModule.join(__dirname, '..', '..', 'admin-frontend');
|
||||
const TAILWIND_CSS = pathModule.join(__dirname, '..', '..', '..', 'css', 'tailwind.css');
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.png': 'image/png',
|
||||
'.json': 'application/json',
|
||||
'.webmanifest': 'application/manifest+json'
|
||||
};
|
||||
|
||||
function isPathInsideDir(candidate, baseDir) {
|
||||
const resolvedBase = pathModule.resolve(baseDir);
|
||||
const resolvedCandidate = pathModule.resolve(candidate);
|
||||
return resolvedCandidate === resolvedBase
|
||||
|| resolvedCandidate.startsWith(resolvedBase + pathModule.sep);
|
||||
}
|
||||
|
||||
async function serveFile(filePath, res) {
|
||||
const ext = pathModule.extname(filePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
const body = await fs.readFile(filePath);
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function serveAdminFrontendAsset(urlPath, res) {
|
||||
if (!/^\/(?:ui\/[\w.-]+\.js|[\w.-]+\.(?:css|js|svg|ico|png|json|webmanifest))$/.test(urlPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relativePath = urlPath.replace(/^\//, '');
|
||||
const filePath = pathModule.join(ADMIN_FRONTEND_DIR, relativePath);
|
||||
if (!isPathInsideDir(filePath, ADMIN_FRONTEND_DIR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await serveFile(filePath, res);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Failed to load asset');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStaticRoutes(req, res) {
|
||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||
const method = req.method;
|
||||
|
||||
const tabs = ['domains', 'host', 'local-dns', 'entries', 'peers', 'certs', 'interfaces', 'logs', 'settings', 'stats'];
|
||||
const tabs = ['domains', 'host', 'local-dns', 'entries', 'peers', 'certs', 'interfaces', 'logs', 'settings', 'stats', 'backups', 'plugins'];
|
||||
if (method === 'GET' && urlPath.startsWith('/') && tabs.includes(urlPath.substring(1))) {
|
||||
res.writeHead(302, { 'Location': `/#${urlPath.substring(1)}` });
|
||||
res.end();
|
||||
@@ -16,12 +70,10 @@ async function handleStaticRoutes(req, res) {
|
||||
if (method === 'GET' && urlPath === '/') {
|
||||
logDebug('Admin', 'Serving admin panel HTML');
|
||||
try {
|
||||
const html = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', 'index.html'), 'utf8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(html);
|
||||
await serveFile(pathModule.join(ADMIN_FRONTEND_DIR, 'index.html'), res);
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to serve index.html: ${err.message}`);
|
||||
res.writeHead(500);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Failed to load admin panel');
|
||||
}
|
||||
return true;
|
||||
@@ -29,68 +81,33 @@ async function handleStaticRoutes(req, res) {
|
||||
|
||||
if (method === 'GET' && urlPath === '/tailwind.css') {
|
||||
try {
|
||||
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', '..', 'css', 'tailwind.css'), 'utf8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/css' });
|
||||
res.end(css);
|
||||
await serveFile(TAILWIND_CSS, res);
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to serve tailwind.css: ${err.message}`);
|
||||
res.writeHead(500);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Failed to load Tailwind CSS');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === 'GET' && urlPath === '/styles.css') {
|
||||
try {
|
||||
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', 'styles.css'), 'utf8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/css' });
|
||||
res.end(css);
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to serve styles.css: ${err.message}`);
|
||||
res.writeHead(500);
|
||||
res.end('Failed to load styles');
|
||||
if (method === 'GET') {
|
||||
const served = await serveAdminFrontendAsset(urlPath, res);
|
||||
if (served) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Serve frontend JavaScript files
|
||||
if (method === 'GET' && (urlPath === '/admin.js' || urlPath === '/utils.js' || urlPath === '/ws-client.js')) {
|
||||
if (method === 'GET' && urlPath === '/favicon.ico') {
|
||||
try {
|
||||
const fileName = urlPath.substring(1); // Remove leading '/'
|
||||
const js = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', fileName), 'utf8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||
res.end(js);
|
||||
await serveFile(pathModule.join(ADMIN_FRONTEND_DIR, 'favicon.svg'), res);
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
|
||||
res.writeHead(500);
|
||||
res.end('Failed to load script');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Serve UI module files
|
||||
if (method === 'GET' && urlPath.startsWith('/ui/') && urlPath.endsWith('.js')) {
|
||||
try {
|
||||
const fileName = urlPath.substring(1); // Remove leading '/' -> 'ui/filename.js'
|
||||
const js = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', fileName), 'utf8');
|
||||
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||
res.end(js);
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
|
||||
res.writeHead(404);
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Not Found');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (urlPath === '/favicon.ico') {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = { handleStaticRoutes };
|
||||
|
||||
|
||||
@@ -15,11 +15,17 @@ const {
|
||||
onLogWsClose,
|
||||
closeLogWebSocketState
|
||||
} = require('./log-websocket');
|
||||
const {
|
||||
scheduleInterfacesBroadcast,
|
||||
broadcastInterfacesList,
|
||||
getInterfacesSnapshot
|
||||
} = require('./interfaces-broadcast');
|
||||
|
||||
const adminWss = new WebSocket.Server({ noServer: true });
|
||||
const adminClients = new Set();
|
||||
let healthBroadcastInterval = null;
|
||||
let domainsBroadcastInterval = null;
|
||||
let interfacesBroadcastInterval = null;
|
||||
|
||||
// Fetch the complete resolved domains list
|
||||
async function getResolvedDomainsList() {
|
||||
@@ -104,6 +110,14 @@ adminWss.on('connection', (ws) => {
|
||||
logError('Admin', `Error sending initial domains list: ${err.message}`);
|
||||
});
|
||||
|
||||
getInterfacesSnapshot().then(snapshot => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(snapshot));
|
||||
}
|
||||
}).catch(err => {
|
||||
logError('Admin', `Error sending initial interfaces list: ${err.message}`);
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
@@ -178,6 +192,15 @@ function broadcast(msg) {
|
||||
setTimeout(() => {
|
||||
broadcastDomainsList();
|
||||
}, 100); // Small delay to ensure the update-database message is processed first
|
||||
scheduleInterfacesBroadcast();
|
||||
}
|
||||
|
||||
if (
|
||||
msg.type === 'update-holesail-clients' ||
|
||||
msg.type === 'update-holesail' ||
|
||||
msg.type === 'update-interfaces'
|
||||
) {
|
||||
scheduleInterfacesBroadcast();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +225,9 @@ function closeAllWebSockets() {
|
||||
// Stop domains broadcasts
|
||||
stopDomainsBroadcasts();
|
||||
|
||||
// Stop interfaces broadcasts
|
||||
stopInterfacesBroadcasts();
|
||||
|
||||
// Close the WebSocket server
|
||||
try {
|
||||
adminWss.close();
|
||||
@@ -291,12 +317,39 @@ function broadcastDomainsList() {
|
||||
});
|
||||
}
|
||||
|
||||
function startInterfacesBroadcasts() {
|
||||
if (interfacesBroadcastInterval) return;
|
||||
|
||||
broadcastInterfacesList().catch(err => {
|
||||
logError('Admin', `Error in initial interfaces broadcast: ${err.message}`);
|
||||
});
|
||||
|
||||
interfacesBroadcastInterval = setInterval(() => {
|
||||
broadcastInterfacesList().catch(err => {
|
||||
logError('Admin', `Error in periodic interfaces broadcast: ${err.message}`);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
logDebug('Admin', 'Interfaces broadcasts started');
|
||||
}
|
||||
|
||||
function stopInterfacesBroadcasts() {
|
||||
if (interfacesBroadcastInterval) {
|
||||
clearInterval(interfacesBroadcastInterval);
|
||||
interfacesBroadcastInterval = null;
|
||||
logDebug('Admin', 'Interfaces broadcasts stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Start health broadcasts when module loads
|
||||
startHealthBroadcasts();
|
||||
|
||||
// Start domains broadcasts when module loads
|
||||
startDomainsBroadcasts();
|
||||
|
||||
// Start interfaces broadcasts when module loads
|
||||
startInterfacesBroadcasts();
|
||||
|
||||
module.exports = {
|
||||
adminWss,
|
||||
adminClients,
|
||||
@@ -307,6 +360,8 @@ module.exports = {
|
||||
startDomainsBroadcasts,
|
||||
stopDomainsBroadcasts,
|
||||
broadcastDomainsList,
|
||||
getResolvedDomainsList
|
||||
getResolvedDomainsList,
|
||||
startInterfacesBroadcasts,
|
||||
stopInterfacesBroadcasts
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,52 @@ if (location.hash === '#domains') {
|
||||
|
||||
// Initialize when DOM is ready
|
||||
function initializeApp() {
|
||||
const SCROLLABLE_TABS = ['stats', 'plugins', 'settings'];
|
||||
|
||||
function updateAdminContentMode(tabId) {
|
||||
const contentEl = document.getElementById('admin-content');
|
||||
if (!contentEl) return;
|
||||
if (SCROLLABLE_TABS.includes(tabId)) {
|
||||
contentEl.classList.remove('admin-content--fill');
|
||||
contentEl.classList.add('admin-content--scroll');
|
||||
} else {
|
||||
contentEl.classList.remove('admin-content--scroll');
|
||||
contentEl.classList.add('admin-content--fill');
|
||||
}
|
||||
}
|
||||
|
||||
function updateAdminNavActive(tabId) {
|
||||
document.querySelectorAll('.admin-nav-link[data-tab-id]').forEach((btn) => {
|
||||
const isActive = btn.dataset.tabId === tabId;
|
||||
btn.classList.toggle('admin-nav-link--active', isActive);
|
||||
btn.setAttribute('aria-current', isActive ? 'page' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function closeAdminSidebar() {
|
||||
const sidebar = document.getElementById('admin-sidebar');
|
||||
const overlay = document.getElementById('admin-sidebar-overlay');
|
||||
const toggle = document.getElementById('admin-sidebar-toggle');
|
||||
if (sidebar) sidebar.classList.remove('admin-sidebar--open');
|
||||
if (overlay) overlay.classList.remove('admin-sidebar-overlay--visible');
|
||||
if (toggle) toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function toggleAdminSidebar() {
|
||||
const sidebar = document.getElementById('admin-sidebar');
|
||||
const overlay = document.getElementById('admin-sidebar-overlay');
|
||||
const toggle = document.getElementById('admin-sidebar-toggle');
|
||||
if (!sidebar) return;
|
||||
const willOpen = !sidebar.classList.contains('admin-sidebar--open');
|
||||
sidebar.classList.toggle('admin-sidebar--open', willOpen);
|
||||
if (overlay) overlay.classList.toggle('admin-sidebar-overlay--visible', willOpen);
|
||||
if (toggle) toggle.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||
}
|
||||
|
||||
window.closeAdminSidebar = closeAdminSidebar;
|
||||
window.toggleAdminSidebar = toggleAdminSidebar;
|
||||
window.updateAdminNavActive = updateAdminNavActive;
|
||||
|
||||
// showTab function - must be defined after all modules are loaded
|
||||
function showTab(tabId) {
|
||||
// If we're already on this tab and it's visible, don't do anything to avoid breaking the page
|
||||
@@ -21,8 +67,12 @@ function initializeApp() {
|
||||
if (tabEl) tabEl.classList.remove('hidden');
|
||||
window.activeTab = tabId;
|
||||
|
||||
updateAdminContentMode(tabId);
|
||||
updateAdminNavActive(tabId);
|
||||
closeAdminSidebar();
|
||||
|
||||
// Enable/disable body scrolling based on tab
|
||||
const scrollableTabs = ['stats', 'plugins', 'settings'];
|
||||
const scrollableTabs = SCROLLABLE_TABS;
|
||||
if (scrollableTabs.includes(tabId)) {
|
||||
document.body.classList.remove('no-scroll');
|
||||
} else {
|
||||
@@ -53,6 +103,14 @@ function initializeApp() {
|
||||
} else {
|
||||
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||
}
|
||||
if (window.stopInterfacesPollingFallback) window.stopInterfacesPollingFallback();
|
||||
} else if (tabId === 'interfaces') {
|
||||
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||
if (!window.wsConnected) {
|
||||
if (window.startInterfacesPollingFallback) window.startInterfacesPollingFallback();
|
||||
} else if (window.stopInterfacesPollingFallback) {
|
||||
window.stopInterfacesPollingFallback();
|
||||
}
|
||||
} else if (tabId === 'local-dns') {
|
||||
// Show records sub-tab by default
|
||||
if (window.showSubTab) {
|
||||
@@ -60,6 +118,7 @@ function initializeApp() {
|
||||
}
|
||||
} else {
|
||||
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||
if (window.stopInterfacesPollingFallback) window.stopInterfacesPollingFallback();
|
||||
}
|
||||
if (tabId === 'logs') {
|
||||
if (window.renderLogs) window.renderLogs();
|
||||
@@ -135,9 +194,9 @@ function initializeApp() {
|
||||
// Update button styles
|
||||
mainTab.querySelectorAll(`[id^="${mainTabId}-subtab-"]`).forEach(btn => {
|
||||
if (btn.id === `${mainTabId}-subtab-${subTabId}`) {
|
||||
btn.className = 'px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover';
|
||||
btn.className = 'admin-subtab-btn admin-subtab-btn--active px-4 py-2 rounded transition-colors';
|
||||
} else {
|
||||
btn.className = 'px-4 py-2 theme-button-info rounded transition-colors';
|
||||
btn.className = 'admin-subtab-btn px-4 py-2 rounded transition-colors';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -201,6 +260,7 @@ function initializeApp() {
|
||||
|
||||
// Set initial scroll state
|
||||
const scrollableTabs = ['stats', 'plugins', 'settings'];
|
||||
document.body.classList.add('admin-app');
|
||||
if (!scrollableTabs.includes(tabId)) {
|
||||
document.body.classList.add('no-scroll');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="P2NS">
|
||||
<rect width="32" height="32" rx="6" fill="#0f1419"/>
|
||||
<rect x="4" y="4" width="24" height="24" rx="4" fill="#6366f1"/>
|
||||
<path fill="#f1f5f9" d="M9 21V11h3.2c2.4 0 3.9 1.2 3.9 3.1 0 1.3-.7 2.3-1.9 2.8L18 21h-2.6l-2.8-3.5H11.4V21H9zm2.4-5.6h.8c1.1 0 1.7-.5 1.7-1.4s-.6-1.3-1.7-1.3h-.8v2.7zM19.2 21V11h2.4v10h-2.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -1,75 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en" class="dark" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="theme-color" content="#0a0e1a">
|
||||
<title>P2NS Admin Panel</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/tailwind.css">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
body.admin-app {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #0a0e1a 0%, #0f1419 50%, #1a1f2e 100%);
|
||||
background-attachment: fixed;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* Disable body scrolling for fixed-height tabs */
|
||||
body.no-scroll {
|
||||
body.admin-app.no-scroll {
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.min.js"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/[email protected]/css/xterm.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body style="color: var(--text-primary);">
|
||||
<div class="container mx-auto max-w-7xl">
|
||||
<div class="flex justify-between items-center mb-8 flex-wrap gap-4">
|
||||
<h1 class="text-4xl font-extrabold" style="background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">P2NS Admin Panel</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<div id="status-indicator" class="px-4 py-2 rounded-lg"></div>
|
||||
<button id="refresh-button" onclick="handleRefresh()" class="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors" title="Gracefully stop the process">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
<body class="admin-app" style="color: var(--text-primary);">
|
||||
<div class="admin-shell">
|
||||
<aside id="admin-sidebar" class="admin-sidebar" aria-label="Main navigation">
|
||||
<div class="admin-sidebar-brand">
|
||||
<span class="admin-sidebar-brand-title">P2NS</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="admin-sidebar-nav">
|
||||
<div class="admin-nav-section">
|
||||
<p class="admin-nav-section-label">Core</p>
|
||||
<button type="button" data-tab-id="domains" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('domains'); else location.hash = 'domains';"><i class="fas fa-globe admin-nav-icon" aria-hidden="true"></i>Domains</button>
|
||||
<button type="button" data-tab-id="host" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('host'); else location.hash = 'host';"><i class="fas fa-server admin-nav-icon" aria-hidden="true"></i>Host</button>
|
||||
<button type="button" data-tab-id="entries" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('entries'); else location.hash = 'entries';"><i class="fas fa-list admin-nav-icon" aria-hidden="true"></i>Entries</button>
|
||||
<button type="button" data-tab-id="peers" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('peers'); else location.hash = 'peers';"><i class="fas fa-network-wired admin-nav-icon" aria-hidden="true"></i>Peers</button>
|
||||
</div>
|
||||
<div class="admin-nav-section">
|
||||
<p class="admin-nav-section-label">Network</p>
|
||||
<button type="button" data-tab-id="local-dns" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('local-dns'); else location.hash = 'local-dns';"><i class="fas fa-sitemap admin-nav-icon" aria-hidden="true"></i>Local DNS</button>
|
||||
<button type="button" data-tab-id="interfaces" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('interfaces'); else location.hash = 'interfaces';"><i class="fas fa-ethernet admin-nav-icon" aria-hidden="true"></i>Interfaces</button>
|
||||
</div>
|
||||
<div class="admin-nav-section">
|
||||
<p class="admin-nav-section-label">Security</p>
|
||||
<button type="button" data-tab-id="certs" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('certs'); else location.hash = 'certs';"><i class="fas fa-certificate admin-nav-icon" aria-hidden="true"></i>Certificates</button>
|
||||
</div>
|
||||
<div class="admin-nav-section">
|
||||
<p class="admin-nav-section-label">Monitoring</p>
|
||||
<button type="button" data-tab-id="stats" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('stats'); else location.hash = 'stats';"><i class="fas fa-chart-line admin-nav-icon" aria-hidden="true"></i>Stats</button>
|
||||
<button type="button" data-tab-id="logs" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('logs'); else location.hash = 'logs';"><i class="fas fa-terminal admin-nav-icon" aria-hidden="true"></i>Logs</button>
|
||||
</div>
|
||||
<div class="admin-nav-section">
|
||||
<p class="admin-nav-section-label">System</p>
|
||||
<button type="button" data-tab-id="backups" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('backups'); else location.hash = 'backups';"><i class="fas fa-database admin-nav-icon" aria-hidden="true"></i>Backups</button>
|
||||
<button type="button" data-tab-id="plugins" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('plugins'); else location.hash = 'plugins';"><i class="fas fa-puzzle-piece admin-nav-icon" aria-hidden="true"></i>Plugins</button>
|
||||
<button type="button" data-tab-id="settings" class="admin-nav-link" onclick="if(window.navigateToTab) window.navigateToTab('settings'); else location.hash = 'settings';"><i class="fas fa-cog admin-nav-icon" aria-hidden="true"></i>Settings</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="admin-sidebar-footer">
|
||||
<span class="admin-sidebar-footer-text">P2NS Network</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<nav class="flex justify-center mb-8 space-x-4 flex-wrap">
|
||||
<!-- Core Management -->
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('domains'); else location.hash = 'domains';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Domains</button>
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('host'); else location.hash = 'host';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Host</button>
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('entries'); else location.hash = 'entries';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Entries</button>
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('peers'); else location.hash = 'peers';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Peers</button>
|
||||
<!-- Network -->
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('local-dns'); else location.hash = 'local-dns';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Local DNS</button>
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('interfaces'); else location.hash = 'interfaces';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Interfaces</button>
|
||||
<!-- Security -->
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('certs'); else location.hash = 'certs';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Certificates</button>
|
||||
<!-- Monitoring -->
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('stats'); else location.hash = 'stats';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Stats</button>
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('logs'); else location.hash = 'logs';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Logs</button>
|
||||
<!-- Configuration -->
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('backups'); else location.hash = 'backups';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Backups</button>
|
||||
<!-- Plugins -->
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('plugins'); else location.hash = 'plugins';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Plugins</button>
|
||||
<button onclick="if(window.navigateToTab) window.navigateToTab('settings'); else location.hash = 'settings';" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover m-1">Settings</button>
|
||||
</nav>
|
||||
<div id="admin-sidebar-overlay" class="admin-sidebar-overlay" onclick="if(window.closeAdminSidebar) window.closeAdminSidebar();" aria-hidden="true"></div>
|
||||
|
||||
<div id="domains" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<div class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<button type="button" id="admin-sidebar-toggle" class="admin-sidebar-toggle" onclick="if(window.toggleAdminSidebar) window.toggleAdminSidebar();" aria-label="Toggle navigation menu" aria-expanded="false">
|
||||
<i class="fas fa-bars" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="admin-topbar-spacer"></div>
|
||||
<div id="status-indicator" class="admin-status-indicator"></div>
|
||||
<div class="admin-alerts-wrap">
|
||||
<button type="button" id="admin-alerts-button" class="admin-topbar-action admin-alerts-bell" title="Alerts" aria-label="Alerts" aria-expanded="false" aria-haspopup="true">
|
||||
<i class="fas fa-bell" aria-hidden="true"></i>
|
||||
<span id="admin-alerts-badge" class="admin-alerts-badge">0</span>
|
||||
</button>
|
||||
<div id="admin-alerts-panel" class="admin-alerts-panel" role="dialog" aria-label="Alerts">
|
||||
<header class="admin-alerts-panel-header">
|
||||
<h3 class="admin-alerts-panel-title">Alerts</h3>
|
||||
<button type="button" class="admin-alerts-panel-close" onclick="if(window.closeAdminAlertsPanel) window.closeAdminAlertsPanel();" aria-label="Close alerts">
|
||||
<i class="fas fa-xmark" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div id="admin-alerts-empty" class="admin-alerts-empty admin-alerts-empty--visible">
|
||||
<i class="fas fa-bell-slash admin-alerts-empty-icon" aria-hidden="true"></i>
|
||||
<p class="admin-alerts-empty-title">All clear</p>
|
||||
<p class="admin-alerts-empty-text theme-text-tertiary">No active warnings or issues.</p>
|
||||
</div>
|
||||
<div id="admin-alerts-list" class="admin-alerts-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="refresh-button" onclick="handleRefresh()" class="admin-topbar-action" title="Gracefully stop the process" aria-label="Refresh">
|
||||
<i class="fas fa-redo" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
<div id="admin-content" class="admin-content admin-content--fill">
|
||||
<div id="domains" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0" style="color: var(--text-primary);">
|
||||
Domains
|
||||
<button onclick="openInfoModal('domains')" class="text-sm px-3 py-1 rounded transition-colors" style="background: var(--bg-glass); border: 1px solid var(--border-color); color: var(--text-secondary);" onmouseover="this.style.background='var(--bg-glass-hover)'" onmouseout="this.style.background='var(--bg-glass)'">Info</button>
|
||||
<button onclick="openInfoModal('domains')" class="theme-button-info text-sm px-3 py-1 rounded">Info</button>
|
||||
</h2>
|
||||
<div class="mb-6 flex-shrink-0">
|
||||
<input id="search-domains" type="text" placeholder="Search domains..." class="w-full p-3 rounded-lg focus:outline-none transition-all mb-3" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary);" onfocus="this.style.borderColor='var(--primary)'; this.style.boxShadow='0 0 0 3px rgba(99, 102, 241, 0.1)'" onblur="this.style.borderColor='var(--border-color)'; this.style.boxShadow='none'" oninput="filterDomains()">
|
||||
<input id="search-domains" type="text" placeholder="Search domains..." class="theme-input w-full p-3 rounded-lg focus:outline-none mb-3" oninput="filterDomains()">
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<select id="filter-consensus-status" onchange="filterDomains(); saveDomainFilterSettings()" class="flex-1 min-w-[150px] p-3 rounded-lg focus:outline-none transition-all" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary);" onfocus="this.style.borderColor='var(--primary)'; this.style.boxShadow='0 0 0 3px rgba(99, 102, 241, 0.1)'" onblur="this.style.borderColor='var(--border-color)'; this.style.boxShadow='none'">
|
||||
<select id="filter-consensus-status" onchange="filterDomains(); saveDomainFilterSettings()" class="theme-input flex-1 min-w-[150px] p-3 rounded-lg focus:outline-none">
|
||||
<option value="all" selected>All Consensus Status</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="internal">Internal</option>
|
||||
@@ -80,12 +125,12 @@
|
||||
<option value="error">Error</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
<select id="filter-hash-type" onchange="filterDomains(); saveDomainFilterSettings()" class="flex-1 min-w-[150px] p-3 rounded-lg focus:outline-none transition-all" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary);" onfocus="this.style.borderColor='var(--primary)'; this.style.boxShadow='0 0 0 3px rgba(99, 102, 241, 0.1)'" onblur="this.style.borderColor='var(--border-color)'; this.style.boxShadow='none'">
|
||||
<select id="filter-hash-type" onchange="filterDomains(); saveDomainFilterSettings()" class="theme-input flex-1 min-w-[150px] p-3 rounded-lg focus:outline-none">
|
||||
<option value="all">All Hash Types</option>
|
||||
<option value="internal">Internal</option>
|
||||
<option value="holesail" selected>Holesail Hash</option>
|
||||
</select>
|
||||
<select id="filter-ownership" onchange="filterDomains(); saveDomainFilterSettings()" class="flex-1 min-w-[150px] p-3 rounded-lg focus:outline-none transition-all" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary);" onfocus="this.style.borderColor='var(--primary)'; this.style.boxShadow='0 0 0 3px rgba(99, 102, 241, 0.1)'" onblur="this.style.borderColor='var(--border-color)'; this.style.boxShadow='none'">
|
||||
<select id="filter-ownership" onchange="filterDomains(); saveDomainFilterSettings()" class="theme-input flex-1 min-w-[150px] p-3 rounded-lg focus:outline-none">
|
||||
<option value="all" selected>All Ownership</option>
|
||||
<option value="local">Local</option>
|
||||
<option value="remote">Remote</option>
|
||||
@@ -93,8 +138,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto overflow-y-auto flex-1 min-h-0">
|
||||
<table class="w-full rounded-lg" style="background: var(--bg-glass); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid var(--border-color); box-shadow: var(--shadow-lg);">
|
||||
<thead class="sticky top-0 z-10" style="background: var(--bg-secondary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);">
|
||||
<table class="theme-table w-full rounded-lg">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Domain</th>
|
||||
<th class="p-3 text-left">Hash</th>
|
||||
@@ -112,14 +157,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="local-dns" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<div id="local-dns" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Local DNS
|
||||
</h2>
|
||||
<div class="flex gap-2 mb-4 flex-shrink-0">
|
||||
<button onclick="showSubTab('local-dns', 'records')" id="local-dns-subtab-records" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">DNS Records</button>
|
||||
<button onclick="showSubTab('local-dns', 'conflicts')" id="local-dns-subtab-conflicts" class="px-4 py-2 theme-button-info rounded transition-colors">DNS Conflicts</button>
|
||||
<button onclick="showSubTab('local-dns', 'p2p-conflicts')" id="local-dns-subtab-p2p-conflicts" class="px-4 py-2 theme-button-info rounded transition-colors">P2P Conflicts</button>
|
||||
<div class="admin-subtabs flex gap-2 mb-4 flex-shrink-0">
|
||||
<button onclick="showSubTab('local-dns', 'records')" id="local-dns-subtab-records" class="admin-subtab-btn admin-subtab-btn--active px-4 py-2 rounded transition-colors">DNS Records</button>
|
||||
<button onclick="showSubTab('local-dns', 'conflicts')" id="local-dns-subtab-conflicts" class="admin-subtab-btn px-4 py-2 rounded transition-colors">DNS Conflicts</button>
|
||||
<button onclick="showSubTab('local-dns', 'p2p-conflicts')" id="local-dns-subtab-p2p-conflicts" class="admin-subtab-btn px-4 py-2 rounded transition-colors">P2P Conflicts</button>
|
||||
</div>
|
||||
|
||||
<!-- DNS Records Sub-tab -->
|
||||
@@ -204,7 +249,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="entries" class="tab-content hidden flex flex-col" style="height: calc(100vh - 250px); max-height: calc(100vh - 250px);">
|
||||
<div id="entries" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Autopass Entries (<span id="entries-count">0</span>)
|
||||
<button onclick="openInfoModal('entries')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
@@ -227,7 +272,7 @@
|
||||
<div id="entriesPagination" class="hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="peers" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<div id="peers" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Connected Peers <span id="peers-count" class="text-lg"></span>
|
||||
<button onclick="openInfoModal('peers')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
@@ -252,59 +297,180 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="certs" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Domain Certificates
|
||||
<button onclick="openInfoModal('certs')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
</h2>
|
||||
<div class="mb-6 flex-shrink-0">
|
||||
<input id="search-certs" type="text" placeholder="Search certificates..." class="w-full p-3 rounded-lg theme-input focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterCerts()">
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0">
|
||||
<ul id="certsList" class="space-y-3"></ul>
|
||||
<div id="certsScrollSentinel" class="scroll-sentinel"></div>
|
||||
</div>
|
||||
<div id="certsPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
|
||||
<div class="mt-4 flex-shrink-0">
|
||||
<input id="cert-domain" placeholder="Domain for Cert" class="p-3 theme-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary">
|
||||
<button onclick="generateCert()" class="ml-2 px-4 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Generate Cert</button>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
CA Management
|
||||
<button onclick="openInfoModal('ca-management')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
</h2>
|
||||
<div class="flex-shrink-0">
|
||||
<button onclick="regenerateCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover mr-4">Regenerate Root CA</button>
|
||||
<button onclick="installCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Install Root CA</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="interfaces" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Virtual Interfaces
|
||||
<button onclick="openInfoModal('interfaces')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
</h2>
|
||||
<div class="mb-6 flex-shrink-0">
|
||||
<input id="search-interfaces" type="text" placeholder="Search interfaces..." class="w-full p-3 rounded-lg theme-input focus:outline-none focus:ring-2 focus:ring-primary" oninput="filterInterfaces()">
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||
<div class="overflow-x-auto overflow-y-auto flex-1 min-h-0">
|
||||
<table class="w-full theme-table rounded-lg">
|
||||
<thead class="theme-table thead sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Domain</th>
|
||||
<th class="p-3 text-left">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interfacesTable"></tbody>
|
||||
</table>
|
||||
<div id="interfacesScrollSentinel" class="scroll-sentinel"></div>
|
||||
<div id="certs" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<header class="admin-tab-header flex-shrink-0">
|
||||
<div class="admin-tab-header-main">
|
||||
<h2 class="admin-tab-title">Certificates</h2>
|
||||
<p class="admin-tab-description">Manage TLS certificates for domains and the local root certificate authority.</p>
|
||||
</div>
|
||||
<div id="interfacesPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
|
||||
<button type="button" onclick="openInfoModal('certs')" class="theme-button-info text-sm px-3 py-1 rounded">Info</button>
|
||||
</header>
|
||||
|
||||
<div class="certs-layout flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="theme-card admin-panel certs-panel lg:col-span-2 flex flex-col min-h-0">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Domain certificates</h3>
|
||||
<p class="admin-panel-subtitle">Click a domain to view PEM details.</p>
|
||||
</div>
|
||||
<div class="admin-toolbar flex-shrink-0">
|
||||
<label class="admin-search-field">
|
||||
<i class="fas fa-magnifying-glass admin-search-icon" aria-hidden="true"></i>
|
||||
<input id="search-certs" type="search" placeholder="Search certificates…" class="theme-input admin-search-input" oninput="filterCerts()" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<div class="cert-list-scroll flex-1 min-h-0 overflow-y-auto">
|
||||
<ul id="certsList" class="cert-list"></ul>
|
||||
<div id="certsEmpty" class="cert-empty">
|
||||
<i class="fas fa-certificate cert-empty-icon" aria-hidden="true"></i>
|
||||
<p class="cert-empty-title">No certificates yet</p>
|
||||
<p class="cert-empty-text theme-text-tertiary">Generate a certificate for a domain below.</p>
|
||||
</div>
|
||||
<div id="certsEnd" class="cert-list-end" aria-live="polite">You have reached the end</div>
|
||||
<div id="certsScrollSentinel" class="scroll-sentinel"></div>
|
||||
</div>
|
||||
<div id="certsPagination" class="hidden"></div>
|
||||
<div class="admin-panel-footer cert-generate-bar flex-shrink-0">
|
||||
<label class="cert-generate-field">
|
||||
<span class="cert-generate-label">Domain</span>
|
||||
<input id="cert-domain" type="text" placeholder="example.p2p" class="theme-input cert-generate-input" autocomplete="off">
|
||||
</label>
|
||||
<button type="button" onclick="generateCert()" class="admin-btn admin-btn--primary">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> Generate certificate
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="theme-card admin-panel cert-ca-panel flex flex-col">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Root CA</h3>
|
||||
<p class="admin-panel-subtitle">Signs all domain certificates on this node.</p>
|
||||
</div>
|
||||
<div class="cert-ca-note">
|
||||
<i class="fas fa-triangle-exclamation" aria-hidden="true"></i>
|
||||
<p>Regenerating the root CA invalidates every existing certificate. You will need to reinstall the CA and regenerate domain certs.</p>
|
||||
</div>
|
||||
<div class="cert-ca-actions">
|
||||
<button type="button" onclick="openInfoModal('ca-management')" class="admin-btn admin-btn--secondary admin-btn--sm w-full">
|
||||
<i class="fas fa-circle-info" aria-hidden="true"></i> About CA management
|
||||
</button>
|
||||
<button type="button" onclick="installCA()" class="admin-btn admin-btn--primary w-full">
|
||||
<i class="fas fa-download" aria-hidden="true"></i> Install root CA
|
||||
</button>
|
||||
<button type="button" onclick="regenerateCA()" class="admin-btn admin-btn--danger w-full">
|
||||
<i class="fas fa-rotate-right" aria-hidden="true"></i> Regenerate root CA
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logs" class="tab-content hidden" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<div id="interfaces" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<header class="admin-tab-header flex-shrink-0">
|
||||
<div class="admin-tab-header-main">
|
||||
<h2 class="admin-tab-title">Virtual Interfaces</h2>
|
||||
<p class="admin-tab-description">Domain-to-IP mappings for local routing, subnet usage, and tunnel bindings.</p>
|
||||
</div>
|
||||
<button type="button" onclick="openInfoModal('interfaces')" class="theme-button-info text-sm px-3 py-1 rounded">Info</button>
|
||||
</header>
|
||||
|
||||
<div class="interfaces-layout flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<section class="theme-card admin-panel interfaces-panel lg:col-span-2 flex flex-col min-h-0">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Domain mappings</h3>
|
||||
<p class="admin-panel-subtitle"><span id="interfacesCount">0</span> assigned</p>
|
||||
</div>
|
||||
<div class="admin-toolbar flex-shrink-0">
|
||||
<label class="admin-search-field">
|
||||
<i class="fas fa-magnifying-glass admin-search-icon" aria-hidden="true"></i>
|
||||
<input id="search-interfaces" type="search" placeholder="Search by domain, IP, or subnet…" class="theme-input admin-search-input" oninput="filterInterfaces()" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<div class="interfaces-table-scroll flex-1 min-h-0 overflow-auto">
|
||||
<table class="w-full theme-table interfaces-table">
|
||||
<thead class="theme-table thead sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="p-3 text-left">Domain</th>
|
||||
<th class="p-3 text-left">IP address</th>
|
||||
<th class="p-3 text-left">Type</th>
|
||||
<th class="p-3 text-left">Subnet</th>
|
||||
<th class="p-3 text-left">Status</th>
|
||||
<th class="p-3 text-left">Tunnels</th>
|
||||
<th class="p-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interfacesTable"></tbody>
|
||||
</table>
|
||||
<div id="interfacesEmpty" class="iface-empty">
|
||||
<i class="fas fa-ethernet iface-empty-icon" aria-hidden="true"></i>
|
||||
<p class="iface-empty-title">No interface mappings</p>
|
||||
<p class="iface-empty-text theme-text-tertiary">Domains receive virtual IPs when resolved or when Holesail clients connect.</p>
|
||||
</div>
|
||||
<div id="interfacesFilteredEmpty" class="iface-empty">
|
||||
<i class="fas fa-filter iface-empty-icon" aria-hidden="true"></i>
|
||||
<p class="iface-empty-title">No matching mappings</p>
|
||||
<p class="iface-empty-text theme-text-tertiary">Try a different search term.</p>
|
||||
</div>
|
||||
<div id="interfacesEnd" class="iface-list-end" aria-live="polite">You have reached the end</div>
|
||||
<div id="interfacesScrollSentinel" class="scroll-sentinel"></div>
|
||||
</div>
|
||||
<div id="interfacesPagination" class="hidden"></div>
|
||||
</section>
|
||||
|
||||
<aside class="interfaces-sidebar flex flex-col gap-4 min-h-0">
|
||||
<section class="theme-card admin-panel interfaces-summary-panel">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Overview</h3>
|
||||
<p class="admin-panel-subtitle" id="interfacesStatusLine">Loading…</p>
|
||||
</div>
|
||||
<dl class="iface-stat-grid" id="interfacesStatGrid">
|
||||
<div class="iface-stat">
|
||||
<dt>Mappings</dt>
|
||||
<dd id="ifaceStatMappings">—</dd>
|
||||
</div>
|
||||
<div class="iface-stat">
|
||||
<dt>On system</dt>
|
||||
<dd id="ifaceStatConfigured">—</dd>
|
||||
</div>
|
||||
<div class="iface-stat">
|
||||
<dt>Tunnel links</dt>
|
||||
<dd id="ifaceStatConnections">—</dd>
|
||||
</div>
|
||||
<div class="iface-stat">
|
||||
<dt>Clients</dt>
|
||||
<dd id="ifaceStatClients">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="theme-card admin-panel interfaces-subnets-panel flex flex-col min-h-0">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Subnets</h3>
|
||||
<p class="admin-panel-subtitle">Capacity across configured ranges</p>
|
||||
</div>
|
||||
<div id="interfacesSubnetsList" class="iface-subnets-list flex-1 min-h-0 overflow-y-auto">
|
||||
<p class="iface-subnets-empty theme-text-tertiary">No subnet configuration</p>
|
||||
</div>
|
||||
<div class="admin-panel-footer">
|
||||
<button type="button" onclick="if(window.navigateToTab) window.navigateToTab('settings'); else location.hash='settings';" class="admin-btn admin-btn--secondary admin-btn--sm w-full">
|
||||
<i class="fas fa-cog" aria-hidden="true"></i> Subnet settings
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="theme-card admin-panel interfaces-actions-panel">
|
||||
<div class="admin-panel-header">
|
||||
<h3 class="admin-panel-title">Maintenance</h3>
|
||||
<p class="admin-panel-subtitle">Stop tunnels and remove virtual IPs from the loopback interface.</p>
|
||||
</div>
|
||||
<button type="button" onclick="cleanupInterfaces()" class="admin-btn admin-btn--danger w-full">
|
||||
<i class="fas fa-broom" aria-hidden="true"></i> Cleanup interfaces
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logs" class="tab-content hidden admin-tab-panel--fixed flex flex-col">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
Logs
|
||||
<button onclick="openInfoModal('logs')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
@@ -353,13 +519,13 @@
|
||||
<div id="terminal" class="bg-black rounded-lg overflow-hidden h-96"></div>
|
||||
</div>
|
||||
|
||||
<div id="host" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<div id="host" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Holesail
|
||||
</h2>
|
||||
<div class="flex gap-2 mb-4 flex-shrink-0">
|
||||
<button onclick="showSubTab('host', 'servers')" id="host-subtab-servers" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Servers</button>
|
||||
<button onclick="showSubTab('host', 'clients')" id="host-subtab-clients" class="px-4 py-2 theme-button-info rounded transition-colors">Clients</button>
|
||||
<div class="admin-subtabs flex gap-2 mb-4 flex-shrink-0">
|
||||
<button onclick="showSubTab('host', 'servers')" id="host-subtab-servers" class="admin-subtab-btn admin-subtab-btn--active px-4 py-2 rounded transition-colors">Servers</button>
|
||||
<button onclick="showSubTab('host', 'clients')" id="host-subtab-clients" class="admin-subtab-btn px-4 py-2 rounded transition-colors">Clients</button>
|
||||
</div>
|
||||
|
||||
<!-- Servers Sub-tab -->
|
||||
@@ -427,7 +593,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings" class="tab-content hidden">
|
||||
<div id="settings" class="tab-content hidden admin-tab-panel--scroll">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
Settings
|
||||
@@ -441,7 +607,7 @@
|
||||
<div id="settingsContainer" class="space-y-6"></div>
|
||||
</div>
|
||||
|
||||
<div id="plugins" class="tab-content hidden">
|
||||
<div id="plugins" class="tab-content hidden admin-tab-panel--scroll">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||
Plugins
|
||||
<button onclick="openInfoModal('plugins')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
@@ -452,7 +618,7 @@
|
||||
<div id="pluginsContainer" class="space-y-6"></div>
|
||||
</div>
|
||||
|
||||
<div id="backups" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
||||
<div id="backups" class="tab-content hidden flex flex-col admin-tab-panel--fixed">
|
||||
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||
Backup & Restore
|
||||
<button onclick="openInfoModal('backups')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
||||
@@ -479,7 +645,7 @@
|
||||
<button id="create-backup-btn" onclick="createBackup(this)" class="mt-4 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover flex-shrink-0">Create Backup</button>
|
||||
</div>
|
||||
|
||||
<div id="stats" class="tab-content hidden">
|
||||
<div id="stats" class="tab-content hidden admin-tab-panel--scroll">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
Statistics & Metrics
|
||||
@@ -502,7 +668,7 @@
|
||||
<option value="360">Last 6 hours</option>
|
||||
<option value="1440" selected>Last 24 hours</option>
|
||||
</select>
|
||||
<button onclick="exportStats()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Export Data</button>
|
||||
<button onclick="exportStats()" class="px-4 py-2 theme-button-info rounded">Export Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1017,7 +1183,7 @@
|
||||
<p class="text-sm text-gray-400">Diagnose RPC invite flow (invite.request, invite.deliver, invite.ack on p2ns.core-request-rpc), peer connections, pending acks, and master queue state.</p>
|
||||
<div class="flex space-x-2">
|
||||
<button onclick="runInviteDiagnostics()" class="flex-1 px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Run Invite Diagnostics</button>
|
||||
<button onclick="cleanDnsPassStorage()" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">🗑️ Clean & Restart</button>
|
||||
<button onclick="cleanDnsPassStorage()" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700">Clean & Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1030,6 +1196,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="addDomainModal" class="p-6 theme-glass rounded-lg shadow-xl w-full max-w-md">
|
||||
<h3 class="text-xl font-bold mb-4">Add New Domain</h3>
|
||||
@@ -1070,18 +1239,23 @@
|
||||
</dialog>
|
||||
|
||||
<dialog id="certDetailsModal" class="p-6 theme-glass rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-2xl font-bold theme-text-primary">Certificate Details</h3>
|
||||
<button onclick="document.getElementById('certDetailsModal').close()" class="theme-text-tertiary hover:text-gray-700 dark:hover:text-gray-200 text-3xl leading-none font-bold">×</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto mb-4">
|
||||
<div class="rounded-lg p-4 theme-input" style="background: rgba(15, 20, 30, 0.6); backdrop-filter: blur(25px) saturate(200%); -webkit-backdrop-filter: blur(25px) saturate(200%);">
|
||||
<pre id="cert-details-content" class="whitespace-pre-wrap break-all text-sm font-mono theme-text-primary leading-relaxed"></pre>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold theme-text-primary">Certificate details</h3>
|
||||
<p id="cert-details-domain" class="text-sm theme-text-tertiary mt-1 font-mono break-all"></p>
|
||||
</div>
|
||||
<button type="button" onclick="document.getElementById('certDetailsModal').close()" class="admin-btn admin-btn--secondary admin-btn--sm cert-modal-close" aria-label="Close">
|
||||
<i class="fas fa-xmark" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 pt-4 border-t border-color: var(--border-color);">
|
||||
<button onclick="copyCertDetails()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors">Copy</button>
|
||||
<button onclick="document.getElementById('certDetailsModal').close()" class="px-4 py-2 theme-button-info theme-text-primary rounded theme-glass-hover transition-colors">Close</button>
|
||||
<div class="flex-1 overflow-y-auto mb-4 min-h-0">
|
||||
<pre id="cert-details-content" class="cert-details-pre whitespace-pre-wrap break-all text-sm font-mono theme-text-primary leading-relaxed"></pre>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-4 border-t" style="border-color: var(--border-color);">
|
||||
<button type="button" onclick="copyCertDetails()" class="admin-btn admin-btn--secondary">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||
</button>
|
||||
<button type="button" onclick="document.getElementById('certDetailsModal').close()" class="admin-btn admin-btn--primary">Close</button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -1180,12 +1354,12 @@
|
||||
</dialog>
|
||||
|
||||
<dialog id="resetIdentityModal" class="p-6 theme-glass rounded-lg shadow-xl w-full max-w-md">
|
||||
<h3 class="text-xl font-bold mb-4 text-red-600 dark:text-red-400">⚠️ Reset Identity</h3>
|
||||
<h3 class="text-lg font-semibold mb-4 text-red-400"><i class="fas fa-triangle-exclamation mr-2" aria-hidden="true"></i>Reset Identity</h3>
|
||||
<p class="text-sm theme-text-primary mb-3">
|
||||
<strong>Warning:</strong> This action will permanently delete your current identity keypair (<code class="theme-glass px-1 rounded">cache/keypair.json</code>) and generate a new one.
|
||||
</p>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 mb-3">
|
||||
<p class="text-sm text-red-800 dark:text-red-300 font-semibold mb-2">⚠️ This action cannot be undone!</p>
|
||||
<p class="text-sm text-red-300 font-semibold mb-2">This action cannot be undone.</p>
|
||||
<ul class="text-xs text-red-700 dark:text-red-400 list-disc list-inside space-y-1">
|
||||
<li>You will lose access to all domains you have claimed</li>
|
||||
<li>Your claims will become unrecoverable unless network consensus allows you to reclaim them</li>
|
||||
@@ -1262,7 +1436,6 @@
|
||||
<button id="dns-server-submit" onclick="submitDnsServer(this)" class="px-4 py-2 bg-primary text-white rounded hover:bg-primary-hover">Add</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
<div id="notifications" class="fixed bottom-4 right-4 flex flex-col-reverse space-y-2" style="z-index: 99999;"></div>
|
||||
|
||||
@@ -1274,6 +1447,8 @@
|
||||
<script src="utils.js"></script>
|
||||
<script src="ui/core.js"></script>
|
||||
<script src="ui/notifications.js"></script>
|
||||
<script src="ui/alerts.js"></script>
|
||||
<script src="ui/alerts-sync.js"></script>
|
||||
<script src="ui/confirmation-modal.js"></script>
|
||||
<script src="ws-client.js"></script>
|
||||
<script src="ui/domains.js"></script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* P2NS Admin — theme aligned with peer.directory
|
||||
* Canonical token source; also inlined at the top of styles.css for serving reliability.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Backgrounds */
|
||||
--admin-bg: #0a0e1a;
|
||||
--admin-bg-muted: #0f1419;
|
||||
--admin-bg-subtle: #1a1f2e;
|
||||
--admin-bg-elevated: #1a1f2e;
|
||||
--admin-bg-glass: rgba(255, 255, 255, 0.03);
|
||||
--admin-bg-glass-hover: rgba(255, 255, 255, 0.06);
|
||||
--admin-bg-glass-strong: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Sidebar */
|
||||
--admin-sidebar: #0f1419;
|
||||
--admin-sidebar-fg: #f1f5f9;
|
||||
--admin-sidebar-fg-muted: #94a3b8;
|
||||
--admin-sidebar-border: rgba(255, 255, 255, 0.08);
|
||||
--admin-sidebar-hover: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* Text */
|
||||
--admin-fg: #f1f5f9;
|
||||
--admin-fg-muted: #94a3b8;
|
||||
--admin-fg-subtle: #64748b;
|
||||
|
||||
/* Borders */
|
||||
--admin-border: rgba(255, 255, 255, 0.08);
|
||||
--admin-border-strong: rgba(255, 255, 255, 0.15);
|
||||
|
||||
/* Brand — peer.directory indigo / violet */
|
||||
--admin-brand: #6366f1;
|
||||
--admin-brand-hover: #4f46e5;
|
||||
--admin-brand-light: #818cf8;
|
||||
--admin-brand-muted: rgba(99, 102, 241, 0.12);
|
||||
--admin-brand-border: rgba(99, 102, 241, 0.25);
|
||||
|
||||
--admin-accent: #818cf8;
|
||||
--admin-accent-pink: #ec4899;
|
||||
--admin-secondary: #8b5cf6;
|
||||
|
||||
/* Semantic */
|
||||
--admin-success: #10b981;
|
||||
--admin-success-bg: rgba(16, 185, 129, 0.12);
|
||||
--admin-success-border: rgba(16, 185, 129, 0.35);
|
||||
|
||||
--admin-warning: #f59e0b;
|
||||
--admin-warning-bg: rgba(245, 158, 11, 0.12);
|
||||
--admin-warning-border: rgba(245, 158, 11, 0.35);
|
||||
|
||||
--admin-danger: #ef4444;
|
||||
--admin-danger-bg: rgba(239, 68, 68, 0.12);
|
||||
--admin-danger-border: rgba(239, 68, 68, 0.35);
|
||||
|
||||
/* Layout */
|
||||
--admin-topbar-height: 3rem;
|
||||
--admin-sidebar-width: 14rem;
|
||||
|
||||
--admin-row-hover: rgba(255, 255, 255, 0.03);
|
||||
|
||||
--admin-radius-sm: 0.375rem;
|
||||
--admin-radius-md: 0.5rem;
|
||||
--admin-radius-lg: 0.75rem;
|
||||
|
||||
--admin-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--admin-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--admin-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.4);
|
||||
--admin-shadow-glow: 0 0 30px rgba(99, 102, 241, 0.4);
|
||||
|
||||
--admin-scrollbar-size: 6px;
|
||||
--admin-scrollbar-track: transparent;
|
||||
--admin-scrollbar-thumb: rgba(255, 255, 255, 0.14);
|
||||
--admin-scrollbar-thumb-hover: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ function connectWebSocket() {
|
||||
window.wsConnected = true;
|
||||
window.wsReconnectAttempts = 0;
|
||||
stopPollingFallback();
|
||||
if (window.stopInterfacesPollingFallback) {
|
||||
window.stopInterfacesPollingFallback();
|
||||
}
|
||||
if (window.stopStatsPollingFallback) {
|
||||
window.stopStatsPollingFallback();
|
||||
}
|
||||
@@ -63,6 +66,15 @@ function connectWebSocket() {
|
||||
if (window.startDomainsUpdates) {
|
||||
window.startDomainsUpdates();
|
||||
}
|
||||
if (window.startInterfacesUpdates) {
|
||||
window.startInterfacesUpdates();
|
||||
}
|
||||
if (window.scheduleAdminAlertsRefresh) {
|
||||
window.scheduleAdminAlertsRefresh();
|
||||
}
|
||||
if (window.syncClientAdminAlerts) {
|
||||
window.syncClientAdminAlerts();
|
||||
}
|
||||
|
||||
// Request domains list from server
|
||||
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
||||
@@ -85,6 +97,12 @@ function connectWebSocket() {
|
||||
if (window.stopDomainsUpdates) {
|
||||
window.stopDomainsUpdates();
|
||||
}
|
||||
if (window.stopInterfacesUpdates) {
|
||||
window.stopInterfacesUpdates();
|
||||
}
|
||||
if (window.syncClientAdminAlerts) {
|
||||
window.syncClientAdminAlerts();
|
||||
}
|
||||
|
||||
// Clear domains data and show disconnected state when WebSocket disconnects
|
||||
window.domainsData = [];
|
||||
@@ -106,6 +124,9 @@ function connectWebSocket() {
|
||||
if (window.activeTab === 'host') {
|
||||
startPollingFallback();
|
||||
}
|
||||
if (window.activeTab === 'interfaces') {
|
||||
startInterfacesPollingFallback();
|
||||
}
|
||||
if (window.activeTab === 'stats' && window.startStatsPollingFallback) {
|
||||
window.startStatsPollingFallback();
|
||||
}
|
||||
@@ -117,6 +138,9 @@ function connectWebSocket() {
|
||||
if (window.activeTab === 'host') {
|
||||
startPollingFallback();
|
||||
}
|
||||
if (window.activeTab === 'interfaces') {
|
||||
startInterfacesPollingFallback();
|
||||
}
|
||||
if (window.activeTab === 'stats' && window.startStatsPollingFallback) {
|
||||
window.startStatsPollingFallback();
|
||||
}
|
||||
@@ -200,6 +224,7 @@ function connectWebSocket() {
|
||||
if (window.activeTab === 'stats' && window.applyHealthPayload) {
|
||||
window.applyHealthPayload(data);
|
||||
}
|
||||
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||
return;
|
||||
}
|
||||
if (data.type === 'update-plugins' && window.activeTab === 'plugins') {
|
||||
@@ -215,6 +240,13 @@ function connectWebSocket() {
|
||||
if (window.genericFetch) window.genericFetch('settings', true);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'interfaces-list') {
|
||||
if (window.applyInterfacesSnapshot) {
|
||||
window.applyInterfacesSnapshot(data);
|
||||
}
|
||||
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||
return;
|
||||
}
|
||||
if (data.type === 'domains-list') {
|
||||
// Update domains data directly from WebSocket
|
||||
if (data.domains) {
|
||||
@@ -245,6 +277,7 @@ function connectWebSocket() {
|
||||
window.genericFilter('domains');
|
||||
}
|
||||
}
|
||||
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -261,6 +294,7 @@ function connectWebSocket() {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||
} else if (data.type === 'log-snapshot' && window.applyLogSnapshot) {
|
||||
window.applyLogSnapshot(data);
|
||||
} else if (data.type === 'file-log' && window.applyFileLog) {
|
||||
@@ -336,31 +370,34 @@ function applyStatusPayload(data) {
|
||||
if (!data) return;
|
||||
window.latestStatusPayload = data;
|
||||
let text;
|
||||
let color = 'bg-blue-600';
|
||||
let statusClass = 'status-indicator--ok';
|
||||
if (data.isShuttingDown) {
|
||||
text = 'Gracefully Cleaning....';
|
||||
color = 'bg-orange-500';
|
||||
text = 'Gracefully cleaning…';
|
||||
statusClass = 'status-indicator--shutdown';
|
||||
} else if (data.isMaster) {
|
||||
text = `This is Master • Peers: ${data.peersCount}`;
|
||||
text = `This is Master · Peers: ${data.peersCount}`;
|
||||
statusClass = 'status-indicator--ok';
|
||||
} else if (data.isConnected) {
|
||||
text = `Connected to Master • Peers: ${data.peersCount}`;
|
||||
color = 'bg-blue-500';
|
||||
text = `Connected to Master · Peers: ${data.peersCount}`;
|
||||
statusClass = 'status-indicator--ok';
|
||||
} else if (data.peersCount > 0) {
|
||||
text = 'Requesting access...';
|
||||
color = 'bg-blue-300';
|
||||
text = 'Requesting access…';
|
||||
statusClass = 'status-indicator--pending';
|
||||
} else {
|
||||
text = 'Searching for peers...';
|
||||
color = 'bg-blue-300';
|
||||
text = 'Searching for peers…';
|
||||
statusClass = 'status-indicator--searching';
|
||||
}
|
||||
if (!window.wsConnected) {
|
||||
text += ' (Polling)';
|
||||
color = 'bg-yellow-500';
|
||||
statusClass = 'status-indicator--polling';
|
||||
}
|
||||
const indicator = document.getElementById('status-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = text;
|
||||
indicator.className = `px-4 py-2 rounded-lg status-indicator-glass ${color}`;
|
||||
indicator.style.color = 'var(--text-primary)';
|
||||
indicator.className = `admin-status-indicator ${statusClass}`;
|
||||
}
|
||||
if (window.syncClientAdminAlerts) {
|
||||
window.syncClientAdminAlerts();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,8 +417,7 @@ async function updateStatus() {
|
||||
const indicator = document.getElementById('status-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = 'Status unknown';
|
||||
indicator.className = 'px-4 py-2 rounded-lg status-indicator-glass bg-blue-900';
|
||||
indicator.style.color = 'var(--text-primary)';
|
||||
indicator.className = 'admin-status-indicator status-indicator--unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +459,41 @@ function stopDomainsUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
function startInterfacesPollingFallback() {
|
||||
if (window.interfacesPollingInterval) return;
|
||||
window.interfacesPollingInterval = setInterval(() => {
|
||||
if (window.activeTab === 'interfaces' && !window.wsConnected && window.genericFetch) {
|
||||
window.genericFetch('interfaces', true);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopInterfacesPollingFallback() {
|
||||
if (window.interfacesPollingInterval) {
|
||||
clearInterval(window.interfacesPollingInterval);
|
||||
window.interfacesPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startInterfacesUpdates() {
|
||||
if (window.interfacesUpdateInterval) {
|
||||
clearInterval(window.interfacesUpdateInterval);
|
||||
}
|
||||
// HTTP fallback when WebSocket misses a push (same pattern as domains tab)
|
||||
window.interfacesUpdateInterval = setInterval(() => {
|
||||
if (window.wsConnected && window.genericFetch) {
|
||||
window.genericFetch('interfaces', window.activeTab === 'interfaces');
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function stopInterfacesUpdates() {
|
||||
if (window.interfacesUpdateInterval) {
|
||||
clearInterval(window.interfacesUpdateInterval);
|
||||
window.interfacesUpdateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (window.ConfirmationModal) {
|
||||
const confirmed = await window.ConfirmationModal.warning(
|
||||
@@ -491,6 +562,10 @@ window.startStatusUpdates = startStatusUpdates;
|
||||
window.stopStatusUpdates = stopStatusUpdates;
|
||||
window.startDomainsUpdates = startDomainsUpdates;
|
||||
window.stopDomainsUpdates = stopDomainsUpdates;
|
||||
window.startInterfacesUpdates = startInterfacesUpdates;
|
||||
window.stopInterfacesUpdates = stopInterfacesUpdates;
|
||||
window.startInterfacesPollingFallback = startInterfacesPollingFallback;
|
||||
window.stopInterfacesPollingFallback = stopInterfacesPollingFallback;
|
||||
window.handleRefresh = handleRefresh;
|
||||
|
||||
// Initialize WebSocket connection
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
const state = require('../infrastructure/state');
|
||||
const { getAdminHealthPayload, getAdminStatusPayload } = require('./admin-backend/admin-snapshot');
|
||||
const { getResolvedDomainsList } = require('./admin-backend/websocket');
|
||||
const { buildInterfacesResponse } = require('./interfaces-data');
|
||||
|
||||
function pushAlert(alerts, alert) {
|
||||
alerts.push({
|
||||
severity: 'warning',
|
||||
details: [],
|
||||
...alert
|
||||
});
|
||||
}
|
||||
|
||||
async function collectDnsConflictAlerts(alerts) {
|
||||
const dualDomains = state.domainsWithBoth ? [...state.domainsWithBoth] : [];
|
||||
if (dualDomains.length === 0) return;
|
||||
|
||||
if (dualDomains.length === 1) {
|
||||
pushAlert(alerts, {
|
||||
id: `dns-conflict-${dualDomains[0]}`,
|
||||
severity: 'warning',
|
||||
source: 'Local DNS',
|
||||
tab: 'local-dns',
|
||||
subTab: 'conflicts',
|
||||
title: `DNS mode conflict: ${dualDomains[0]}`,
|
||||
message: 'This domain has both P2P and public DNS records. Choose which source to use.',
|
||||
details: [dualDomains[0]]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pushAlert(alerts, {
|
||||
id: 'dns-conflicts-summary',
|
||||
severity: 'warning',
|
||||
source: 'Local DNS',
|
||||
tab: 'local-dns',
|
||||
subTab: 'conflicts',
|
||||
title: `${dualDomains.length} DNS mode conflicts`,
|
||||
message: 'These domains have both P2P and public DNS records configured.',
|
||||
details: dualDomains.slice(0, 12)
|
||||
});
|
||||
}
|
||||
|
||||
async function collectDomainAlerts(alerts) {
|
||||
let domains;
|
||||
try {
|
||||
domains = await getResolvedDomainsList();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMeta = {
|
||||
conflict: { severity: 'error', title: 'Ownership conflict' },
|
||||
tie: { severity: 'warning', title: 'Consensus tie' },
|
||||
insufficient_quorum: { severity: 'warning', title: 'Insufficient quorum' },
|
||||
no_claims: { severity: 'info', title: 'No claims' },
|
||||
error: { severity: 'error', title: 'Consensus error' }
|
||||
};
|
||||
|
||||
for (const item of domains) {
|
||||
const status = item.consensusStatus;
|
||||
if (!status || status === 'resolved' || status === 'internal') continue;
|
||||
const meta = statusMeta[status] || { severity: 'warning', title: status };
|
||||
let message = `Domain consensus status is "${status}".`;
|
||||
if (status === 'conflict') {
|
||||
message = 'You have a local claim but consensus resolved to a different owner.';
|
||||
}
|
||||
pushAlert(alerts, {
|
||||
id: `domain-${status}-${item.domain}`,
|
||||
severity: meta.severity,
|
||||
source: 'Domains',
|
||||
tab: 'domains',
|
||||
title: `${meta.title}: ${item.domain}`,
|
||||
message,
|
||||
details: item.hash && item.hash !== 'internal' ? [item.hash] : []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectHealthAlerts(alerts, health) {
|
||||
if (!health) return;
|
||||
|
||||
if (health.status === 'degraded') {
|
||||
pushAlert(alerts, {
|
||||
id: 'health-degraded',
|
||||
severity: 'warning',
|
||||
source: 'Health',
|
||||
tab: 'stats',
|
||||
title: 'System health degraded',
|
||||
message: 'One or more core services are unhealthy. Check the Stats tab health section.'
|
||||
});
|
||||
}
|
||||
|
||||
const serviceLabels = {
|
||||
dns: 'DNS service',
|
||||
proxy: 'Proxy service',
|
||||
swarm: 'Hyperswarm',
|
||||
corestore: 'Corestore'
|
||||
};
|
||||
|
||||
for (const [key, label] of Object.entries(serviceLabels)) {
|
||||
const service = health.services?.[key] || health.dependencies?.[key === 'corestore' ? 'corestore' : key === 'swarm' ? 'hyperswarm' : key];
|
||||
if (!service) continue;
|
||||
if (service.enabled === false) continue;
|
||||
if (service.healthy === false) {
|
||||
pushAlert(alerts, {
|
||||
id: `health-${key}`,
|
||||
severity: 'error',
|
||||
source: 'Health',
|
||||
tab: 'stats',
|
||||
title: `${label} unhealthy`,
|
||||
message: `${label} is enabled but reporting an unhealthy state.`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectStatusAlerts(alerts, status) {
|
||||
if (!status) return;
|
||||
|
||||
if (status.isShuttingDown) {
|
||||
pushAlert(alerts, {
|
||||
id: 'status-shutdown',
|
||||
severity: 'warning',
|
||||
source: 'System',
|
||||
tab: 'stats',
|
||||
title: 'Graceful shutdown in progress',
|
||||
message: 'The node is shutting down and cleaning up resources.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.isMaster && !status.isConnected && (status.peersCount || 0) === 0) {
|
||||
pushAlert(alerts, {
|
||||
id: 'status-no-peers',
|
||||
severity: 'info',
|
||||
source: 'Network',
|
||||
tab: 'peers',
|
||||
title: 'Searching for peers',
|
||||
message: 'This node is not connected to the network yet and has no peers.'
|
||||
});
|
||||
} else if (!status.isMaster && !status.isConnected && (status.peersCount || 0) > 0) {
|
||||
pushAlert(alerts, {
|
||||
id: 'status-requesting-access',
|
||||
severity: 'warning',
|
||||
source: 'Network',
|
||||
tab: 'peers',
|
||||
title: 'Waiting for network access',
|
||||
message: 'Peers are connected but this node has not joined the master ledger yet.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectInterfaceAlerts(alerts, payload) {
|
||||
if (!payload?.summary) return;
|
||||
const { summary, orphanedIps, interfaces } = payload;
|
||||
|
||||
if (summary.virtualInterfacesEnabled === false) {
|
||||
const envLabel = summary.disableVirtualInterfacesEnv === '' ? '(unset)' : summary.disableVirtualInterfacesEnv;
|
||||
pushAlert(alerts, {
|
||||
id: 'interfaces-disabled',
|
||||
severity: 'warning',
|
||||
source: 'Interfaces',
|
||||
tab: 'interfaces',
|
||||
title: 'Virtual interfaces disabled',
|
||||
message: `IPs are tracked in state only. DISABLE_VIRTUAL_INTERFACES=${envLabel}`
|
||||
});
|
||||
}
|
||||
|
||||
for (const ip of orphanedIps || []) {
|
||||
pushAlert(alerts, {
|
||||
id: `interfaces-orphan-${ip}`,
|
||||
severity: 'warning',
|
||||
source: 'Interfaces',
|
||||
tab: 'interfaces',
|
||||
title: `Orphaned IP ${ip}`,
|
||||
message: 'This address is configured on the loopback interface but is not mapped to any domain.',
|
||||
action: { type: 'remove-orphan-ip', ip }
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of interfaces || []) {
|
||||
if (item.type !== 'virtual' || item.configuredOnSystem) continue;
|
||||
pushAlert(alerts, {
|
||||
id: `interfaces-missing-${item.domain}`,
|
||||
severity: 'warning',
|
||||
source: 'Interfaces',
|
||||
tab: 'interfaces',
|
||||
title: `IP not on system: ${item.domain}`,
|
||||
message: `${item.ip} is mapped in state but not configured on the OS loopback interface.`,
|
||||
details: [item.ip]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectHostAlerts(alerts) {
|
||||
for (const [id, info] of state.holesailInfos || new Map()) {
|
||||
if (info?.state !== 'error') continue;
|
||||
const opts = state.holesailOpts?.get(id);
|
||||
const name = opts?.name || id;
|
||||
pushAlert(alerts, {
|
||||
id: `host-server-error-${id}`,
|
||||
severity: 'error',
|
||||
source: 'Host',
|
||||
tab: 'host',
|
||||
subTab: 'servers',
|
||||
title: `Holesail server error: ${name}`,
|
||||
message: info.error || 'The server is in an error state.',
|
||||
details: opts?.port ? [`Port ${opts.port}`] : []
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, info] of state.holesailClientInfos || new Map()) {
|
||||
if (info?.state !== 'error') continue;
|
||||
const opts = state.holesailClientOpts?.get(id);
|
||||
const label = opts?.domain ? `${opts.domain}:${opts.port}` : id;
|
||||
pushAlert(alerts, {
|
||||
id: `host-client-error-${id}`,
|
||||
severity: 'error',
|
||||
source: 'Host',
|
||||
tab: 'host',
|
||||
subTab: 'clients',
|
||||
title: `Holesail client error: ${label}`,
|
||||
message: info.error || 'The client is in an error state.',
|
||||
details: opts?.domain ? [opts.domain] : []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectInviteDiagnosticsAlerts(alerts) {
|
||||
if (!state.diagnoseInviteIssues && !state.diagnoseInviteIssuesAsync) return;
|
||||
|
||||
let diagnostics;
|
||||
try {
|
||||
diagnostics = typeof state.diagnoseInviteIssuesAsync === 'function'
|
||||
? await state.diagnoseInviteIssuesAsync()
|
||||
: state.diagnoseInviteIssues();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (!diagnostics) return;
|
||||
|
||||
const peers = diagnostics.peers ? Object.values(diagnostics.peers) : [];
|
||||
const totalPeers = peers.length;
|
||||
const failedPeers = peers.filter(p => p.failedInvite).length;
|
||||
const pendingAcks = peers.filter(p => p.pendingAck?.waiting).length;
|
||||
const rpcOpen = peers.filter(p => p.rpc?.ready).length;
|
||||
|
||||
if (failedPeers > 0) {
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-failed-peers',
|
||||
severity: 'error',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: `${failedPeers} peer(s) cannot supply invites`,
|
||||
message: 'Some connected peers failed invite delivery or are unavailable for invites.'
|
||||
});
|
||||
}
|
||||
|
||||
if ((diagnostics.splitBrainWarnings || []).length > 0) {
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-split-brain',
|
||||
severity: 'error',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: 'Split-network detected',
|
||||
message: `${diagnostics.splitBrainWarnings.length} peer(s) report a mismatched network ID.`,
|
||||
details: diagnostics.splitBrainWarnings.slice(0, 8)
|
||||
});
|
||||
}
|
||||
|
||||
if (diagnostics.masterPendingPass) {
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-master-pending',
|
||||
severity: 'warning',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: 'Secondary master waiting for invite',
|
||||
message: 'This master node is waiting for an invite to join the network ledger.'
|
||||
});
|
||||
}
|
||||
|
||||
if ((diagnostics.consecutiveInviteFailures || 0) > 0) {
|
||||
const n = diagnostics.consecutiveInviteFailures;
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-consecutive-failures',
|
||||
severity: n >= 3 ? 'error' : 'warning',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: `${n} consecutive invite failure(s)`,
|
||||
message: 'Recent invite.deliver attempts to peers have failed.'
|
||||
});
|
||||
}
|
||||
|
||||
if ((diagnostics.connectionIssues || []).length > 0) {
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-connection-issues',
|
||||
severity: 'warning',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: `${diagnostics.connectionIssues.length} swarm connection issue(s)`,
|
||||
message: 'Hyperswarm connections have reported issues. See Stats diagnostics.',
|
||||
details: diagnostics.connectionIssues.slice(0, 6)
|
||||
});
|
||||
}
|
||||
|
||||
if (pendingAcks > 0) {
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-pending-acks',
|
||||
severity: 'warning',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: `${pendingAcks} invite pending acknowledgement`,
|
||||
message: 'invite.deliver messages are waiting for invite.ack responses.'
|
||||
});
|
||||
}
|
||||
|
||||
if ((diagnostics.masterQueue || 0) > 0) {
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-master-queue',
|
||||
severity: 'warning',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: `${diagnostics.masterQueue} queued invite request(s)`,
|
||||
message: 'Invite requests are queued because the master is not ready.'
|
||||
});
|
||||
}
|
||||
|
||||
if (totalPeers > 0 && rpcOpen < totalPeers) {
|
||||
pushAlert(alerts, {
|
||||
id: 'invite-rpc-closed',
|
||||
severity: 'warning',
|
||||
source: 'Network',
|
||||
tab: 'stats',
|
||||
title: `${totalPeers - rpcOpen} peer(s) without open RPC`,
|
||||
message: 'Some peers are connected but do not have an open RPC channel.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPluginAlerts(alerts) {
|
||||
try {
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { getAllPluginDomainsFromDisk, getPlugin } = require('../plugins/plugin-handler');
|
||||
const domains = await getAllPluginDomainsFromDisk();
|
||||
for (const domain of domains) {
|
||||
if (getPlugin(domain)) continue;
|
||||
const pluginDir = path.join(process.cwd(), 'plugin-sites', domain);
|
||||
let config = {};
|
||||
try {
|
||||
const configData = await fs.readFile(path.join(pluginDir, 'config.json'), 'utf8');
|
||||
config = JSON.parse(configData);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (config.enabled === false) continue;
|
||||
pushAlert(alerts, {
|
||||
id: `plugin-stopped-${domain}`,
|
||||
severity: 'warning',
|
||||
source: 'Plugins',
|
||||
tab: 'plugins',
|
||||
title: `Plugin stopped: ${config.name || domain}`,
|
||||
message: 'This plugin is enabled but not running. Start it from the Plugins tab.'
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Plugin system may not be initialized yet
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAdminAlerts() {
|
||||
const alerts = [];
|
||||
const [ifacePayload, health, status] = await Promise.all([
|
||||
buildInterfacesResponse(),
|
||||
Promise.resolve(getAdminHealthPayload()),
|
||||
Promise.resolve(getAdminStatusPayload())
|
||||
]);
|
||||
|
||||
collectInterfaceAlerts(alerts, ifacePayload);
|
||||
collectHealthAlerts(alerts, health);
|
||||
collectStatusAlerts(alerts, status);
|
||||
collectHostAlerts(alerts);
|
||||
await collectDnsConflictAlerts(alerts);
|
||||
await collectDomainAlerts(alerts);
|
||||
await collectInviteDiagnosticsAlerts(alerts);
|
||||
await collectPluginAlerts(alerts);
|
||||
|
||||
return {
|
||||
alerts,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { collectAdminAlerts };
|
||||
@@ -11,6 +11,28 @@ const { isSecureHolesailKey } = require('../infrastructure/utils');
|
||||
|
||||
const holesailClientsFile = process.env.HOLESAIL_CLIENTS_FILE || './cache/holesail_clients.json';
|
||||
|
||||
function getHolesailClientStatus(id, opts, info = {}) {
|
||||
if (info.state === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
const child = state.holesailClientChildren.get(id);
|
||||
if (child && !child.killed) {
|
||||
return 'running';
|
||||
}
|
||||
|
||||
const key = `${opts.domain}:${opts.port}`;
|
||||
if (state.holesails.has(key)) {
|
||||
return 'running';
|
||||
}
|
||||
|
||||
if (info.state === 'starting') {
|
||||
return 'starting';
|
||||
}
|
||||
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
async function loadHolesailClients() {
|
||||
state.holesailClientChildren = new Map();
|
||||
state.holesailClientOpts = new Map();
|
||||
@@ -144,6 +166,7 @@ async function saveHolesailClients() {
|
||||
module.exports = {
|
||||
loadHolesailClients,
|
||||
startForkedHolesailClient,
|
||||
saveHolesailClients
|
||||
saveHolesailClients,
|
||||
getHolesailClientStatus
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
const os = require('os');
|
||||
const state = require('../infrastructure/state');
|
||||
const {
|
||||
getConfiguredIPs,
|
||||
ipBelongsToSubnet,
|
||||
getAvailableIPsForSubnet,
|
||||
removeVirtualInterface
|
||||
} = require('../networking/virtual_interfaces');
|
||||
|
||||
function getSubnets() {
|
||||
let subnets = state.subnets || [];
|
||||
if (subnets.length === 0) {
|
||||
const subnetBase = process.env.SUBNET_BASE || '192.168.3';
|
||||
const baseParts = subnetBase.split('.');
|
||||
if (baseParts.length === 3) {
|
||||
subnets = [{
|
||||
base: `${subnetBase}.0`,
|
||||
cidr: 24,
|
||||
startIndex: parseInt(process.env.INITIAL_IP_INDEX || '2', 10),
|
||||
name: 'Default Subnet'
|
||||
}];
|
||||
}
|
||||
}
|
||||
return subnets;
|
||||
}
|
||||
|
||||
function findSubnetForIp(ip, subnets) {
|
||||
if (ip === '127.0.0.1') return null;
|
||||
return subnets.find(subnet => ipBelongsToSubnet(ip, subnet)) || null;
|
||||
}
|
||||
|
||||
function countConnectionsByDomain() {
|
||||
const counts = new Map();
|
||||
for (const key of state.holesails.keys()) {
|
||||
const domain = key.split(':')[0];
|
||||
counts.set(domain, (counts.get(domain) || 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function countClientsByDomain() {
|
||||
const counts = new Map();
|
||||
for (const id of state.holesailClientChildren.keys()) {
|
||||
const opts = state.holesailClientOpts.get(id);
|
||||
const info = state.holesailClientInfos.get(id);
|
||||
const domain = opts?.domain || info?.domain;
|
||||
if (domain) {
|
||||
counts.set(domain, (counts.get(domain) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function isVirtualInterfacesEnabled() {
|
||||
const raw = process.env.DISABLE_VIRTUAL_INTERFACES;
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
return true;
|
||||
}
|
||||
const normalized = String(raw).trim().toLowerCase();
|
||||
return !(normalized === 'true' || normalized === '1' || normalized === 'yes');
|
||||
}
|
||||
|
||||
function isValidIPv4(ip) {
|
||||
if (typeof ip !== 'string' || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(ip)) {
|
||||
return false;
|
||||
}
|
||||
return ip.split('.').every(part => {
|
||||
const n = parseInt(part, 10);
|
||||
return !Number.isNaN(n) && n >= 0 && n <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
async function isOrphanedIp(ip) {
|
||||
if (!isValidIPv4(ip) || ip === '127.0.0.1') {
|
||||
return false;
|
||||
}
|
||||
const configuredIPs = await getConfiguredIPs();
|
||||
if (!configuredIPs.includes(ip)) {
|
||||
return false;
|
||||
}
|
||||
const mappedIPs = new Set(Array.from(state.domainToIPMap.values()));
|
||||
return !mappedIPs.has(ip);
|
||||
}
|
||||
|
||||
async function removeOrphanedInterfaceIp(ip) {
|
||||
if (!isValidIPv4(ip)) {
|
||||
throw new Error('Invalid IP address');
|
||||
}
|
||||
if (!isVirtualInterfacesEnabled()) {
|
||||
throw new Error('Virtual interfaces are disabled');
|
||||
}
|
||||
if (!(await isOrphanedIp(ip))) {
|
||||
throw new Error(`IP ${ip} is not an orphaned interface`);
|
||||
}
|
||||
await removeVirtualInterface(ip);
|
||||
return ip;
|
||||
}
|
||||
|
||||
async function buildInterfacesResponse() {
|
||||
const configuredIPs = await getConfiguredIPs();
|
||||
const mappedIPs = new Set(Array.from(state.domainToIPMap.values()));
|
||||
const reservedIPs = new Set(['127.0.0.1']);
|
||||
const orphanedIps = configuredIPs.filter(ip => !mappedIPs.has(ip) && !reservedIPs.has(ip));
|
||||
|
||||
const subnets = getSubnets();
|
||||
const connectionsByDomain = countConnectionsByDomain();
|
||||
const clientsByDomain = countClientsByDomain();
|
||||
|
||||
const subnetInfo = subnets.map((subnet, index) => {
|
||||
const available = getAvailableIPsForSubnet(subnet);
|
||||
const used = Array.from(state.domainToIPMap.values()).filter(ip => ipBelongsToSubnet(ip, subnet)).length;
|
||||
return {
|
||||
...subnet,
|
||||
index,
|
||||
available,
|
||||
used,
|
||||
remaining: Math.max(0, available - used)
|
||||
};
|
||||
});
|
||||
|
||||
const interfaces = Array.from(state.domainToIPMap.entries()).map(([domain, ip]) => {
|
||||
const subnet = findSubnetForIp(ip, subnets);
|
||||
const type = ip === '127.0.0.1' ? 'internal' : 'virtual';
|
||||
return {
|
||||
domain,
|
||||
ip,
|
||||
type,
|
||||
subnetName: subnet?.name || null,
|
||||
subnetBase: subnet?.base || null,
|
||||
subnetCidr: subnet?.cidr ?? null,
|
||||
configuredOnSystem: type === 'internal' || configuredIPs.includes(ip),
|
||||
activeConnections: connectionsByDomain.get(domain) || 0,
|
||||
activeClients: clientsByDomain.get(domain) || 0
|
||||
};
|
||||
});
|
||||
|
||||
const virtualInterfaces = interfaces.filter(item => item.type === 'virtual');
|
||||
const totalSubnetCapacity = subnetInfo.reduce((sum, subnet) => sum + subnet.available, 0);
|
||||
const totalSubnetUsed = subnetInfo.reduce((sum, subnet) => sum + subnet.used, 0);
|
||||
|
||||
const virtualInterfacesEnabled = isVirtualInterfacesEnabled();
|
||||
|
||||
const summary = {
|
||||
totalMappings: interfaces.length,
|
||||
virtualMappings: virtualInterfaces.length,
|
||||
internalMappings: interfaces.length - virtualInterfaces.length,
|
||||
configuredOnSystem: virtualInterfaces.filter(item => item.configuredOnSystem).length,
|
||||
notConfiguredOnSystem: virtualInterfaces.filter(item => !item.configuredOnSystem).length,
|
||||
orphanedOnSystem: orphanedIps.length,
|
||||
virtualInterfacesEnabled,
|
||||
disableVirtualInterfacesEnv: process.env.DISABLE_VIRTUAL_INTERFACES ?? '',
|
||||
subnetName: state.subnetName || '',
|
||||
platform: os.platform(),
|
||||
totalConnections: state.holesails.size,
|
||||
totalClients: state.holesailClientChildren.size,
|
||||
totalSubnetCapacity,
|
||||
totalSubnetUsed,
|
||||
totalSubnetRemaining: Math.max(0, totalSubnetCapacity - totalSubnetUsed)
|
||||
};
|
||||
|
||||
return { summary, subnets: subnetInfo, orphanedIps, interfaces };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInterfacesResponse,
|
||||
removeOrphanedInterfaceIp,
|
||||
isOrphanedIp,
|
||||
isValidIPv4
|
||||
};
|
||||
@@ -7,7 +7,7 @@ const { validateHolesailClient } = require('../../infrastructure/validation');
|
||||
const { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
|
||||
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
||||
const { startHolesailServer, saveHolesailServers } = require('../holesail-servers');
|
||||
const { startForkedHolesailClient, saveHolesailClients } = require('../holesail-clients');
|
||||
const { startForkedHolesailClient, saveHolesailClients, getHolesailClientStatus } = require('../holesail-clients');
|
||||
const { ensurePortFree } = require('../port-management');
|
||||
const { broadcast } = require('../websocket');
|
||||
|
||||
@@ -38,19 +38,8 @@ async function handleHolesailRoutes(req, res) {
|
||||
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
||||
try {
|
||||
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||
const child = state.holesailClientChildren.get(id);
|
||||
const info = state.holesailClientInfos.get(id) || {};
|
||||
const key = `${opts.domain}:${opts.port}`;
|
||||
const isHolesailActive = state.holesails.has(key);
|
||||
const isChildRunning = child && !child.killed;
|
||||
let status = 'stopped';
|
||||
if (isChildRunning && isHolesailActive && info.state !== 'error') {
|
||||
status = 'running';
|
||||
} else if (isChildRunning || isHolesailActive) {
|
||||
status = 'starting';
|
||||
} else if (info.state === 'error') {
|
||||
status = 'error';
|
||||
}
|
||||
const status = getHolesailClientStatus(id, opts, info);
|
||||
return { id, opts, info: { ...info, state: status } };
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
const state = require('../../infrastructure/state');
|
||||
const { cleanupInterfaces } = require('../../maintenance/cleanup');
|
||||
const { logError } = require('../../infrastructure/logger');
|
||||
const { broadcast } = require('../websocket');
|
||||
const { scheduleInterfacesBroadcast } = require('../admin-backend/interfaces-broadcast');
|
||||
const { buildInterfacesResponse, removeOrphanedInterfaceIp } = require('../interfaces-data');
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch (err) {
|
||||
reject(new Error('Invalid JSON body'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInterfacesRoutes(req, res) {
|
||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||
@@ -9,9 +24,9 @@ async function handleInterfacesRoutes(req, res) {
|
||||
|
||||
if (method === 'GET' && urlPath === '/api/interfaces') {
|
||||
try {
|
||||
const interfaces = Array.from(state.domainToIPMap.entries()).map(([domain, ip]) => ({ domain, ip }));
|
||||
const payload = await buildInterfacesResponse();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(interfaces));
|
||||
res.end(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
||||
res.writeHead(500);
|
||||
@@ -20,10 +35,25 @@ async function handleInterfacesRoutes(req, res) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === 'POST' && urlPath === '/api/interfaces/remove-ip') {
|
||||
try {
|
||||
const { ip } = await readJsonBody(req);
|
||||
const removedIp = await removeOrphanedInterfaceIp(ip);
|
||||
scheduleInterfacesBroadcast();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, ip: removedIp }));
|
||||
} catch (err) {
|
||||
logError('Admin', `Failed to remove orphaned IP: ${err.message}`);
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
|
||||
try {
|
||||
await cleanupInterfaces();
|
||||
broadcast({ type: 'update-interfaces' });
|
||||
scheduleInterfacesBroadcast();
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('OK');
|
||||
} catch (err) {
|
||||
@@ -38,4 +68,3 @@ async function handleInterfacesRoutes(req, res) {
|
||||
}
|
||||
|
||||
module.exports = { handleInterfacesRoutes };
|
||||
|
||||
|
||||
@@ -196,6 +196,12 @@ async function atomicDomainCleanup(domain) {
|
||||
await removeVirtualInterface(ip);
|
||||
state.domainToIPMap.delete(domain);
|
||||
logDebug('DomainCleanup', `Removed IP ${ip} for ${domain}`);
|
||||
try {
|
||||
const { scheduleInterfacesBroadcast } = require('../admin/admin-backend/interfaces-broadcast');
|
||||
scheduleInterfacesBroadcast();
|
||||
} catch (_) {
|
||||
// Admin may not be loaded
|
||||
}
|
||||
} catch (err) {
|
||||
cleanupErrors.push(`Interface removal: ${err.message}`);
|
||||
logError('DomainCleanup', `Error removing interface: ${err.message}`);
|
||||
|
||||
@@ -6,6 +6,15 @@ const { logDebug, logError, logWarn, logInfo } = require('../infrastructure/logg
|
||||
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
function notifyInterfacesChanged() {
|
||||
try {
|
||||
const { scheduleInterfacesBroadcast } = require('../admin/admin-backend/interfaces-broadcast');
|
||||
scheduleInterfacesBroadcast();
|
||||
} catch (err) {
|
||||
logDebug('VirtualInterface', `Could not schedule interfaces broadcast: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to calculate available IPs for a subnet
|
||||
function getAvailableIPsForSubnet(subnet) {
|
||||
const maxIPs = Math.pow(2, 32 - subnet.cidr) - 2; // Subtract network and broadcast
|
||||
@@ -405,6 +414,7 @@ async function createInterfaceForDomain(domain) {
|
||||
// Always set plugin domains to 127.0.0.1
|
||||
state.domainToIPMap.set(domain, pluginIP);
|
||||
logDebug('VirtualInterface', `Domain ${domain} is a plugin/internal domain, using 127.0.0.1 (no virtual interface created)`);
|
||||
notifyInterfacesChanged();
|
||||
return pluginIP;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -426,6 +436,7 @@ async function createInterfaceForDomain(domain) {
|
||||
const placeholderIP = `${subnetBase}${ipIndex}`;
|
||||
state.domainToIPMap.set(domain, placeholderIP);
|
||||
logDebug('VirtualInterface', `Assigned placeholder IP: ${placeholderIP} for domain: ${domain} (interface not created)`);
|
||||
notifyInterfacesChanged();
|
||||
}
|
||||
return state.domainToIPMap.get(domain);
|
||||
}
|
||||
@@ -442,11 +453,13 @@ async function createInterfaceForDomain(domain) {
|
||||
} else {
|
||||
logWarn('VirtualInterface', `IP ${existingIP} for domain ${domain} no longer configured, reassigning`);
|
||||
state.domainToIPMap.delete(domain);
|
||||
notifyInterfacesChanged();
|
||||
}
|
||||
} else {
|
||||
logWarn('VirtualInterface', `Removing invalid existing IP ${existingIP} for domain ${domain}`);
|
||||
await removeVirtualInterface(existingIP);
|
||||
state.domainToIPMap.delete(domain);
|
||||
notifyInterfacesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +488,7 @@ async function createInterfaceForDomain(domain) {
|
||||
state.domainToIPMap.set(domain, assignedIP);
|
||||
logInfo('VirtualInterface', `Assigned virtual interface IP: ${assignedIP} for domain: ${domain}`);
|
||||
state.currentIP++;
|
||||
notifyInterfacesChanged();
|
||||
return assignedIP;
|
||||
} catch (error) {
|
||||
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
|
||||
@@ -543,6 +557,7 @@ async function createInterfaceForDomain(domain) {
|
||||
state.subnetIPCounters.set(subnetIndex, currentIPIndex + 1);
|
||||
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
|
||||
|
||||
notifyInterfacesChanged();
|
||||
return assignedIP;
|
||||
} catch (error) {
|
||||
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
|
||||
@@ -563,5 +578,6 @@ module.exports = {
|
||||
createInterfaceForDomain,
|
||||
getAvailableIPsForSubnet,
|
||||
ipBelongsToSubnet,
|
||||
generateIPFromSubnet
|
||||
generateIPFromSubnet,
|
||||
getConfiguredIPs
|
||||
};
|
||||
+9
-25
@@ -30,7 +30,7 @@ const { addDomain } = require('../core/domains');
|
||||
// Lazy load createInterfaceForDomain to avoid circular dependency
|
||||
// const { createInterfaceForDomain } = require('../networking/virtual_interfaces');
|
||||
const { startHolesailServer, saveHolesailServers } = require('../admin/admin-backend/holesail-servers');
|
||||
const { startForkedHolesailClient, saveHolesailClients } = require('../admin/admin-backend/holesail-clients');
|
||||
const { startForkedHolesailClient, saveHolesailClients, getHolesailClientStatus } = require('../admin/admin-backend/holesail-clients');
|
||||
const { saveBlockedPeers, loadBlockedPeers } = require('../admin/admin-backend/cache');
|
||||
const ca = require('../security/certificate_authority');
|
||||
const http = require('http');
|
||||
@@ -1025,19 +1025,8 @@ const sdk = {
|
||||
*/
|
||||
listClients() {
|
||||
return Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||
const child = state.holesailClientChildren.get(id);
|
||||
const info = state.holesailClientInfos.get(id) || {};
|
||||
const key = `${opts.domain}:${opts.port}`;
|
||||
const isHolesailActive = state.holesails.has(key);
|
||||
const isChildRunning = child && !child.killed;
|
||||
let status = 'stopped';
|
||||
if (isChildRunning && isHolesailActive && info.state !== 'error') {
|
||||
status = 'running';
|
||||
} else if (isChildRunning || isHolesailActive) {
|
||||
status = 'starting';
|
||||
} else if (info.state === 'error') {
|
||||
status = 'error';
|
||||
}
|
||||
const status = getHolesailClientStatus(id, opts, info);
|
||||
return { id, opts, info: { ...info, state: status } };
|
||||
});
|
||||
},
|
||||
@@ -1050,19 +1039,8 @@ const sdk = {
|
||||
getClient(id) {
|
||||
const opts = state.holesailClientOpts.get(id);
|
||||
if (!opts) return null;
|
||||
const child = state.holesailClientChildren.get(id);
|
||||
const info = state.holesailClientInfos.get(id) || {};
|
||||
const key = `${opts.domain}:${opts.port}`;
|
||||
const isHolesailActive = state.holesails.has(key);
|
||||
const isChildRunning = child && !child.killed;
|
||||
let status = 'stopped';
|
||||
if (isChildRunning && isHolesailActive && info.state !== 'error') {
|
||||
status = 'running';
|
||||
} else if (isChildRunning || isHolesailActive) {
|
||||
status = 'starting';
|
||||
} else if (info.state === 'error') {
|
||||
status = 'error';
|
||||
}
|
||||
const status = getHolesailClientStatus(id, opts, info);
|
||||
return { id, opts, info: { ...info, state: status } };
|
||||
},
|
||||
|
||||
@@ -1458,6 +1436,12 @@ const sdk = {
|
||||
// This just removes from the map
|
||||
if (state.domainToIPMap) {
|
||||
state.domainToIPMap.delete(domain);
|
||||
try {
|
||||
const { scheduleInterfacesBroadcast } = require('../admin/admin-backend/interfaces-broadcast');
|
||||
scheduleInterfacesBroadcast();
|
||||
} catch (_) {
|
||||
// Admin may not be loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user