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';
|
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() {
|
async function loadHolesailClients() {
|
||||||
state.holesailClientChildren = new Map();
|
state.holesailClientChildren = new Map();
|
||||||
state.holesailClientOpts = new Map();
|
state.holesailClientOpts = new Map();
|
||||||
@@ -279,6 +302,7 @@ async function saveHolesailClients() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
loadHolesailClients,
|
loadHolesailClients,
|
||||||
startForkedHolesailClient,
|
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 { createInterfaceForDomain } = require('../../../networking/virtual_interfaces');
|
||||||
const { logDebug, logError, logInfo, logWarn } = require('../../../infrastructure/logger');
|
const { logDebug, logError, logInfo, logWarn } = require('../../../infrastructure/logger');
|
||||||
const { startHolesailServer, saveHolesailServers } = require('../holesail-servers');
|
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 { ensurePortFree } = require('../port-management');
|
||||||
const { broadcast } = require('../websocket');
|
const { broadcast } = require('../websocket');
|
||||||
const { getConsensusState, getClaimClients, updateClaimClients } = require('../../../core/core');
|
const { getConsensusState, getClaimClients, updateClaimClients } = require('../../../core/core');
|
||||||
@@ -42,19 +42,8 @@ async function handleHolesailRoutes(req, res) {
|
|||||||
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
||||||
try {
|
try {
|
||||||
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||||
const child = state.holesailClientChildren.get(id);
|
|
||||||
const info = state.holesailClientInfos.get(id) || {};
|
const info = state.holesailClientInfos.get(id) || {};
|
||||||
const key = `${opts.domain}:${opts.port}`;
|
const status = getHolesailClientStatus(id, opts, info);
|
||||||
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';
|
|
||||||
}
|
|
||||||
return { id, opts, info: { ...info, state: status } };
|
return { id, opts, info: { ...info, state: status } };
|
||||||
});
|
});
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const { handleDiagnosticsRoutes } = require('./diagnostics');
|
|||||||
const { handleConsensusRoutes } = require('./consensus');
|
const { handleConsensusRoutes } = require('./consensus');
|
||||||
const { handlePluginsRoutes } = require('./plugins');
|
const { handlePluginsRoutes } = require('./plugins');
|
||||||
const { handleLogsRoutes } = require('./logs');
|
const { handleLogsRoutes } = require('./logs');
|
||||||
|
const { handleAlertsRoutes } = require('./alerts');
|
||||||
|
|
||||||
async function handleAdminRequest(req, res) {
|
async function handleAdminRequest(req, res) {
|
||||||
const url = new URL(req.url, `https://${req.headers.host}`);
|
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 handleConsensusRoutes(req, res)) return;
|
||||||
if (await handlePluginsRoutes(req, res)) return;
|
if (await handlePluginsRoutes(req, res)) return;
|
||||||
if (await handleLogsRoutes(req, res)) return;
|
if (await handleLogsRoutes(req, res)) return;
|
||||||
|
if (await handleAlertsRoutes(req, res)) return;
|
||||||
|
|
||||||
// No route matched
|
// No route matched
|
||||||
res.writeHead(404);
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
res.end('Not Found');
|
res.end('Not Found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
const state = require('../../../infrastructure/state');
|
|
||||||
const { cleanupInterfaces } = require('../../../maintenance/cleanup');
|
const { cleanupInterfaces } = require('../../../maintenance/cleanup');
|
||||||
const { logError } = require('../../../infrastructure/logger');
|
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) {
|
async function handleInterfacesRoutes(req, res) {
|
||||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
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') {
|
if (method === 'GET' && urlPath === '/api/interfaces') {
|
||||||
try {
|
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(interfaces));
|
res.end(JSON.stringify(payload));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
@@ -20,10 +35,25 @@ async function handleInterfacesRoutes(req, res) {
|
|||||||
return true;
|
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') {
|
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
|
||||||
try {
|
try {
|
||||||
await cleanupInterfaces();
|
await cleanupInterfaces();
|
||||||
broadcast({ type: 'update-interfaces' });
|
scheduleInterfacesBroadcast();
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
res.end('OK');
|
res.end('OK');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -38,4 +68,3 @@ async function handleInterfacesRoutes(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handleInterfacesRoutes };
|
module.exports = { handleInterfacesRoutes };
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,65 @@ const fs = require('fs').promises;
|
|||||||
const pathModule = require('path');
|
const pathModule = require('path');
|
||||||
const { logDebug, logError } = require('../../../infrastructure/logger');
|
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) {
|
async function handleStaticRoutes(req, res) {
|
||||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
||||||
const method = req.method;
|
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))) {
|
if (method === 'GET' && urlPath.startsWith('/') && tabs.includes(urlPath.substring(1))) {
|
||||||
res.writeHead(302, { 'Location': `/#${urlPath.substring(1)}` });
|
res.writeHead(302, { 'Location': `/#${urlPath.substring(1)}` });
|
||||||
res.end();
|
res.end();
|
||||||
@@ -16,12 +70,10 @@ async function handleStaticRoutes(req, res) {
|
|||||||
if (method === 'GET' && urlPath === '/') {
|
if (method === 'GET' && urlPath === '/') {
|
||||||
logDebug('Admin', 'Serving admin panel HTML');
|
logDebug('Admin', 'Serving admin panel HTML');
|
||||||
try {
|
try {
|
||||||
const html = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', 'index.html'), 'utf8');
|
await serveFile(pathModule.join(ADMIN_FRONTEND_DIR, 'index.html'), res);
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
||||||
res.end(html);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('Admin', `Failed to serve index.html: ${err.message}`);
|
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');
|
res.end('Failed to load admin panel');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -29,68 +81,33 @@ async function handleStaticRoutes(req, res) {
|
|||||||
|
|
||||||
if (method === 'GET' && urlPath === '/tailwind.css') {
|
if (method === 'GET' && urlPath === '/tailwind.css') {
|
||||||
try {
|
try {
|
||||||
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', '..', 'css', 'tailwind.css'), 'utf8');
|
await serveFile(TAILWIND_CSS, res);
|
||||||
res.writeHead(200, { 'Content-Type': 'text/css' });
|
|
||||||
res.end(css);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('Admin', `Failed to serve tailwind.css: ${err.message}`);
|
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');
|
res.end('Failed to load Tailwind CSS');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && urlPath === '/styles.css') {
|
if (method === 'GET') {
|
||||||
try {
|
const served = await serveAdminFrontendAsset(urlPath, res);
|
||||||
const css = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', 'styles.css'), 'utf8');
|
if (served) {
|
||||||
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');
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve frontend JavaScript files
|
|
||||||
if (method === 'GET' && (urlPath === '/admin.js' || urlPath === '/utils.js' || urlPath === '/ws-client.js')) {
|
|
||||||
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);
|
|
||||||
} 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 === '/favicon.ico') {
|
||||||
if (method === 'GET' && urlPath.startsWith('/ui/') && urlPath.endsWith('.js')) {
|
|
||||||
try {
|
try {
|
||||||
const fileName = urlPath.substring(1); // Remove leading '/' -> 'ui/filename.js'
|
await serveFile(pathModule.join(ADMIN_FRONTEND_DIR, 'favicon.svg'), res);
|
||||||
const js = await fs.readFile(pathModule.join(__dirname, '..', '..', 'admin-frontend', fileName), 'utf8');
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
|
||||||
res.end(js);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('Admin', `Failed to serve ${urlPath}: ${err.message}`);
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
res.writeHead(404);
|
|
||||||
res.end('Not Found');
|
res.end('Not Found');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlPath === '/favicon.ico') {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end('Not Found');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handleStaticRoutes };
|
module.exports = { handleStaticRoutes };
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ const {
|
|||||||
onLogWsClose,
|
onLogWsClose,
|
||||||
closeLogWebSocketState
|
closeLogWebSocketState
|
||||||
} = require('./log-websocket');
|
} = require('./log-websocket');
|
||||||
|
const {
|
||||||
|
scheduleInterfacesBroadcast,
|
||||||
|
broadcastInterfacesList,
|
||||||
|
getInterfacesSnapshot
|
||||||
|
} = require('./interfaces-broadcast');
|
||||||
|
|
||||||
const adminWss = new WebSocket.Server({ noServer: true });
|
const adminWss = new WebSocket.Server({ noServer: true });
|
||||||
const adminClients = new Set();
|
const adminClients = new Set();
|
||||||
let healthBroadcastInterval = null;
|
let healthBroadcastInterval = null;
|
||||||
let domainsBroadcastInterval = null;
|
let domainsBroadcastInterval = null;
|
||||||
|
let interfacesBroadcastInterval = null;
|
||||||
|
|
||||||
// Fetch the complete resolved domains list
|
// Fetch the complete resolved domains list
|
||||||
async function getResolvedDomainsList() {
|
async function getResolvedDomainsList() {
|
||||||
@@ -104,6 +110,14 @@ adminWss.on('connection', (ws) => {
|
|||||||
logError('Admin', `Error sending initial domains list: ${err.message}`);
|
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
|
// Handle incoming messages
|
||||||
ws.on('message', (message) => {
|
ws.on('message', (message) => {
|
||||||
try {
|
try {
|
||||||
@@ -178,6 +192,15 @@ function broadcast(msg) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
broadcastDomainsList();
|
broadcastDomainsList();
|
||||||
}, 100); // Small delay to ensure the update-database message is processed first
|
}, 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
|
// Stop domains broadcasts
|
||||||
stopDomainsBroadcasts();
|
stopDomainsBroadcasts();
|
||||||
|
|
||||||
|
// Stop interfaces broadcasts
|
||||||
|
stopInterfacesBroadcasts();
|
||||||
|
|
||||||
// Close the WebSocket server
|
// Close the WebSocket server
|
||||||
try {
|
try {
|
||||||
adminWss.close();
|
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
|
// Start health broadcasts when module loads
|
||||||
startHealthBroadcasts();
|
startHealthBroadcasts();
|
||||||
|
|
||||||
// Start domains broadcasts when module loads
|
// Start domains broadcasts when module loads
|
||||||
startDomainsBroadcasts();
|
startDomainsBroadcasts();
|
||||||
|
|
||||||
|
// Start interfaces broadcasts when module loads
|
||||||
|
startInterfacesBroadcasts();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
adminWss,
|
adminWss,
|
||||||
adminClients,
|
adminClients,
|
||||||
@@ -307,6 +360,8 @@ module.exports = {
|
|||||||
startDomainsBroadcasts,
|
startDomainsBroadcasts,
|
||||||
stopDomainsBroadcasts,
|
stopDomainsBroadcasts,
|
||||||
broadcastDomainsList,
|
broadcastDomainsList,
|
||||||
getResolvedDomainsList
|
getResolvedDomainsList,
|
||||||
|
startInterfacesBroadcasts,
|
||||||
|
stopInterfacesBroadcasts
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,52 @@ if (location.hash === '#domains') {
|
|||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
function initializeApp() {
|
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
|
// showTab function - must be defined after all modules are loaded
|
||||||
function showTab(tabId) {
|
function showTab(tabId) {
|
||||||
// If we're already on this tab and it's visible, don't do anything to avoid breaking the page
|
// 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');
|
if (tabEl) tabEl.classList.remove('hidden');
|
||||||
window.activeTab = tabId;
|
window.activeTab = tabId;
|
||||||
|
|
||||||
|
updateAdminContentMode(tabId);
|
||||||
|
updateAdminNavActive(tabId);
|
||||||
|
closeAdminSidebar();
|
||||||
|
|
||||||
// Enable/disable body scrolling based on tab
|
// Enable/disable body scrolling based on tab
|
||||||
const scrollableTabs = ['stats', 'plugins', 'settings'];
|
const scrollableTabs = SCROLLABLE_TABS;
|
||||||
if (scrollableTabs.includes(tabId)) {
|
if (scrollableTabs.includes(tabId)) {
|
||||||
document.body.classList.remove('no-scroll');
|
document.body.classList.remove('no-scroll');
|
||||||
} else {
|
} else {
|
||||||
@@ -53,6 +103,14 @@ function initializeApp() {
|
|||||||
} else {
|
} else {
|
||||||
if (window.stopPollingFallback) window.stopPollingFallback();
|
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') {
|
} else if (tabId === 'local-dns') {
|
||||||
// Show records sub-tab by default
|
// Show records sub-tab by default
|
||||||
if (window.showSubTab) {
|
if (window.showSubTab) {
|
||||||
@@ -60,6 +118,7 @@ function initializeApp() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (window.stopPollingFallback) window.stopPollingFallback();
|
if (window.stopPollingFallback) window.stopPollingFallback();
|
||||||
|
if (window.stopInterfacesPollingFallback) window.stopInterfacesPollingFallback();
|
||||||
}
|
}
|
||||||
if (tabId === 'logs') {
|
if (tabId === 'logs') {
|
||||||
if (window.renderLogs) window.renderLogs();
|
if (window.renderLogs) window.renderLogs();
|
||||||
@@ -135,9 +194,9 @@ function initializeApp() {
|
|||||||
// Update button styles
|
// Update button styles
|
||||||
mainTab.querySelectorAll(`[id^="${mainTabId}-subtab-"]`).forEach(btn => {
|
mainTab.querySelectorAll(`[id^="${mainTabId}-subtab-"]`).forEach(btn => {
|
||||||
if (btn.id === `${mainTabId}-subtab-${subTabId}`) {
|
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 {
|
} 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
|
// Set initial scroll state
|
||||||
const scrollableTabs = ['stats', 'plugins', 'settings'];
|
const scrollableTabs = ['stats', 'plugins', 'settings'];
|
||||||
|
document.body.classList.add('admin-app');
|
||||||
if (!scrollableTabs.includes(tabId)) {
|
if (!scrollableTabs.includes(tabId)) {
|
||||||
document.body.classList.add('no-scroll');
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<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="/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" />
|
<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>
|
<style>
|
||||||
body {
|
body.admin-app {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
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: linear-gradient(135deg, #0a0e1a 0%, #0f1419 50%, #1a1f2e 100%);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disable body scrolling for fixed-height tabs */
|
body.admin-app.no-scroll {
|
||||||
body.no-scroll {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100vh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.min.js"></script>
|
<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">
|
<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>
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body style="color: var(--text-primary);">
|
<body class="admin-app" style="color: var(--text-primary);">
|
||||||
<div class="container mx-auto max-w-7xl">
|
<div class="admin-shell">
|
||||||
<div class="flex justify-between items-center mb-8 flex-wrap gap-4">
|
<aside id="admin-sidebar" class="admin-sidebar" aria-label="Main navigation">
|
||||||
<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="admin-sidebar-brand">
|
||||||
<div class="flex items-center gap-2">
|
<span class="admin-sidebar-brand-title">P2NS</span>
|
||||||
<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>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<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>
|
</nav>
|
||||||
|
<div class="admin-sidebar-footer">
|
||||||
|
<span class="admin-sidebar-footer-text">P2NS Network</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div id="domains" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
<div id="admin-sidebar-overlay" class="admin-sidebar-overlay" onclick="if(window.closeAdminSidebar) window.closeAdminSidebar();" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<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);">
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0" style="color: var(--text-primary);">
|
||||||
Domains
|
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>
|
</h2>
|
||||||
<div class="mb-6 flex-shrink-0">
|
<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">
|
<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="all" selected>All Consensus Status</option>
|
||||||
<option value="resolved">Resolved</option>
|
<option value="resolved">Resolved</option>
|
||||||
<option value="internal">Internal</option>
|
<option value="internal">Internal</option>
|
||||||
@@ -80,12 +125,12 @@
|
|||||||
<option value="error">Error</option>
|
<option value="error">Error</option>
|
||||||
<option value="unknown">Unknown</option>
|
<option value="unknown">Unknown</option>
|
||||||
</select>
|
</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="all">All Hash Types</option>
|
||||||
<option value="internal">Internal</option>
|
<option value="internal">Internal</option>
|
||||||
<option value="holesail" selected>Holesail Hash</option>
|
<option value="holesail" selected>Holesail Hash</option>
|
||||||
</select>
|
</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="all" selected>All Ownership</option>
|
||||||
<option value="local">Local</option>
|
<option value="local">Local</option>
|
||||||
<option value="remote">Remote</option>
|
<option value="remote">Remote</option>
|
||||||
@@ -93,8 +138,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto overflow-y-auto flex-1 min-h-0">
|
<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);">
|
<table class="theme-table w-full rounded-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%);">
|
<thead class="sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="p-3 text-left">Domain</th>
|
<th class="p-3 text-left">Domain</th>
|
||||||
<th class="p-3 text-left">Hash</th>
|
<th class="p-3 text-left">Hash</th>
|
||||||
@@ -112,14 +157,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||||
Local DNS
|
Local DNS
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex gap-2 mb-4 flex-shrink-0">
|
<div class="admin-subtabs 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', '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="px-4 py-2 theme-button-info rounded transition-colors">DNS Conflicts</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="px-4 py-2 theme-button-info rounded transition-colors">P2P 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>
|
</div>
|
||||||
|
|
||||||
<!-- DNS Records Sub-tab -->
|
<!-- DNS Records Sub-tab -->
|
||||||
@@ -204,7 +249,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||||
Autopass Entries (<span id="entries-count">0</span>)
|
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>
|
<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 id="entriesPagination" class="hidden"></div>
|
||||||
</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">
|
<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>
|
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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div id="certs" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
<div id="certs" 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">
|
<header class="admin-tab-header flex-shrink-0">
|
||||||
Domain Certificates
|
<div class="admin-tab-header-main">
|
||||||
<button onclick="openInfoModal('certs')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
<h2 class="admin-tab-title">Certificates</h2>
|
||||||
</h2>
|
<p class="admin-tab-description">Manage TLS certificates for domains and the local root certificate authority.</p>
|
||||||
<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>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0">
|
<button type="button" onclick="openInfoModal('certs')" class="theme-button-info text-sm px-3 py-1 rounded">Info</button>
|
||||||
<ul id="certsList" class="space-y-3"></ul>
|
</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 id="certsScrollSentinel" class="scroll-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="certsPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></div>
|
<div id="certsPagination" class="hidden"></div>
|
||||||
<div class="mt-4 flex-shrink-0">
|
<div class="admin-panel-footer cert-generate-bar 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">
|
<label class="cert-generate-field">
|
||||||
<button onclick="generateCert()" class="ml-2 px-4 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Generate Cert</button>
|
<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>
|
</div>
|
||||||
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2 flex-shrink-0">
|
</section>
|
||||||
CA Management
|
|
||||||
<button onclick="openInfoModal('ca-management')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
<section class="theme-card admin-panel cert-ca-panel flex flex-col">
|
||||||
</h2>
|
<div class="admin-panel-header">
|
||||||
<div class="flex-shrink-0">
|
<h3 class="admin-panel-title">Root CA</h3>
|
||||||
<button onclick="regenerateCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover mr-4">Regenerate Root CA</button>
|
<p class="admin-panel-subtitle">Signs all domain certificates on this node.</p>
|
||||||
<button onclick="installCA()" class="px-6 py-3 bg-primary text-white rounded-lg hover:bg-primary-hover">Install Root CA</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div id="interfaces" class="tab-content hidden flex flex-col" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
<div id="interfaces" 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">
|
<header class="admin-tab-header flex-shrink-0">
|
||||||
Virtual Interfaces
|
<div class="admin-tab-header-main">
|
||||||
<button onclick="openInfoModal('interfaces')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
<h2 class="admin-tab-title">Virtual Interfaces</h2>
|
||||||
</h2>
|
<p class="admin-tab-description">Domain-to-IP mappings for local routing, subnet usage, and tunnel bindings.</p>
|
||||||
<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>
|
||||||
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
<button type="button" onclick="openInfoModal('interfaces')" class="theme-button-info text-sm px-3 py-1 rounded">Info</button>
|
||||||
<div class="overflow-x-auto overflow-y-auto flex-1 min-h-0">
|
</header>
|
||||||
<table class="w-full theme-table rounded-lg">
|
|
||||||
|
<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">
|
<thead class="theme-table thead sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="p-3 text-left">Domain</th>
|
<th class="p-3 text-left">Domain</th>
|
||||||
<th class="p-3 text-left">IP</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="interfacesTable"></tbody>
|
<tbody id="interfacesTable"></tbody>
|
||||||
</table>
|
</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 id="interfacesScrollSentinel" class="scroll-sentinel"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="interfacesPagination" class="flex justify-center mt-4 space-x-2 flex-shrink-0"></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>
|
</div>
|
||||||
|
|
||||||
<div id="logs" class="tab-content hidden" style="height: calc(100vh - 200px); max-height: calc(100vh - 200px);">
|
<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">
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
Logs
|
Logs
|
||||||
<button onclick="openInfoModal('logs')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
<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 id="terminal" class="bg-black rounded-lg overflow-hidden h-96"></div>
|
||||||
</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">
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||||
Holesail
|
Holesail
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex gap-2 mb-4 flex-shrink-0">
|
<div class="admin-subtabs 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', '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="px-4 py-2 theme-button-info rounded transition-colors">Clients</button>
|
<button onclick="showSubTab('host', 'clients')" id="host-subtab-clients" class="admin-subtab-btn px-4 py-2 rounded transition-colors">Clients</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Servers Sub-tab -->
|
<!-- Servers Sub-tab -->
|
||||||
@@ -427,7 +593,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||||
Settings
|
Settings
|
||||||
@@ -441,7 +607,7 @@
|
|||||||
<div id="settingsContainer" class="space-y-6"></div>
|
<div id="settingsContainer" class="space-y-6"></div>
|
||||||
</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">
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
Plugins
|
Plugins
|
||||||
<button onclick="openInfoModal('plugins')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
<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 id="pluginsContainer" class="space-y-6"></div>
|
||||||
</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">
|
<h2 class="text-2xl font-bold mb-4 flex items-center gap-2 flex-shrink-0">
|
||||||
Backup & Restore
|
Backup & Restore
|
||||||
<button onclick="openInfoModal('backups')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
|
<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>
|
<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>
|
||||||
|
|
||||||
<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">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||||
Statistics & Metrics
|
Statistics & Metrics
|
||||||
@@ -502,7 +668,7 @@
|
|||||||
<option value="360">Last 6 hours</option>
|
<option value="360">Last 6 hours</option>
|
||||||
<option value="1440" selected>Last 24 hours</option>
|
<option value="1440" selected>Last 24 hours</option>
|
||||||
</select>
|
</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>
|
||||||
</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>
|
<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">
|
<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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1030,6 +1196,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<dialog id="addDomainModal" class="p-6 theme-glass rounded-lg shadow-xl w-full max-w-md">
|
<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>
|
<h3 class="text-xl font-bold mb-4">Add New Domain</h3>
|
||||||
@@ -1070,18 +1239,23 @@
|
|||||||
</dialog>
|
</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">
|
<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">
|
<div class="flex justify-between items-start gap-4 mb-4">
|
||||||
<h3 class="text-2xl font-bold theme-text-primary">Certificate Details</h3>
|
<div>
|
||||||
<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>
|
<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>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto mb-4">
|
<button type="button" onclick="document.getElementById('certDetailsModal').close()" class="admin-btn admin-btn--secondary admin-btn--sm cert-modal-close" aria-label="Close">
|
||||||
<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%);">
|
<i class="fas fa-xmark" aria-hidden="true"></i>
|
||||||
<pre id="cert-details-content" class="whitespace-pre-wrap break-all text-sm font-mono theme-text-primary leading-relaxed"></pre>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
<div class="flex justify-end space-x-2 pt-4 border-t border-color: var(--border-color);">
|
<div class="flex justify-end gap-2 pt-4 border-t" style="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 type="button" onclick="copyCertDetails()" class="admin-btn admin-btn--secondary">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
@@ -1180,12 +1354,12 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="resetIdentityModal" class="p-6 theme-glass rounded-lg shadow-xl w-full max-w-md">
|
<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">
|
<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.
|
<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>
|
</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">
|
<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">
|
<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>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>
|
<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>
|
<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>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="notifications" class="fixed bottom-4 right-4 flex flex-col-reverse space-y-2" style="z-index: 99999;"></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="utils.js"></script>
|
||||||
<script src="ui/core.js"></script>
|
<script src="ui/core.js"></script>
|
||||||
<script src="ui/notifications.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="ui/confirmation-modal.js"></script>
|
||||||
<script src="ws-client.js"></script>
|
<script src="ws-client.js"></script>
|
||||||
<script src="ui/domains.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
|
// Certificates UI functions
|
||||||
|
|
||||||
function regenerateCA() {
|
function regenerateCA() {
|
||||||
if (window.showConfirm) {
|
if (!window.showConfirm) return;
|
||||||
window.showConfirm('Regenerate Root CA?', async () => {
|
window.showConfirm(
|
||||||
|
'Regenerate the root CA? All existing domain certificates will become invalid and must be regenerated.',
|
||||||
|
async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/regenerate-ca', { method: 'POST' });
|
const response = await fetch('/api/regenerate-ca', { method: 'POST' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -13,13 +16,16 @@ function regenerateCA() {
|
|||||||
console.error('Failed to regenerate CA:', err);
|
console.error('Failed to regenerate CA:', err);
|
||||||
if (window.showNotification) window.showNotification('Failed to regenerate CA: ' + err.message, 'error');
|
if (window.showNotification) window.showNotification('Failed to regenerate CA: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
{ type: 'danger', confirmText: 'Regenerate CA' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function installCA() {
|
function installCA() {
|
||||||
if (window.showConfirm) {
|
if (!window.showConfirm) return;
|
||||||
window.showConfirm('Install Root CA?', async () => {
|
window.showConfirm(
|
||||||
|
'Install the root CA into your system trust store?',
|
||||||
|
async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/install-ca', { method: 'POST' });
|
const response = await fetch('/api/install-ca', { method: 'POST' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -30,14 +36,20 @@ function installCA() {
|
|||||||
console.error('Failed to install CA:', err);
|
console.error('Failed to install CA:', err);
|
||||||
if (window.showNotification) window.showNotification('Failed to install CA: ' + err.message, 'error');
|
if (window.showNotification) window.showNotification('Failed to install CA: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
{ type: 'info', confirmText: 'Install CA' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateCert() {
|
async function generateCert() {
|
||||||
const domainEl = document.getElementById('cert-domain');
|
const domainEl = document.getElementById('cert-domain');
|
||||||
if (!domainEl) return;
|
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 {
|
try {
|
||||||
const response = await fetch('/api/generate-cert', {
|
const response = await fetch('/api/generate-cert', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -47,6 +59,7 @@ async function generateCert() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
|
domainEl.value = '';
|
||||||
if (window.showNotification) window.showNotification('Certificate generated successfully');
|
if (window.showNotification) window.showNotification('Certificate generated successfully');
|
||||||
if (window.genericFetch) window.genericFetch('certs', true);
|
if (window.genericFetch) window.genericFetch('certs', true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -56,8 +69,10 @@ async function generateCert() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteCert(domain) {
|
function deleteCert(domain) {
|
||||||
if (window.showConfirm) {
|
if (!window.showConfirm) return;
|
||||||
window.showConfirm(`Delete certificate for ${domain}?`, async () => {
|
window.showConfirm(
|
||||||
|
`Delete the certificate for ${domain}?`,
|
||||||
|
async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/delete-cert', {
|
const response = await fetch('/api/delete-cert', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -73,13 +88,16 @@ function deleteCert(domain) {
|
|||||||
console.error('Failed to delete cert:', err);
|
console.error('Failed to delete cert:', err);
|
||||||
if (window.showNotification) window.showNotification('Failed to delete cert: ' + err.message, 'error');
|
if (window.showNotification) window.showNotification('Failed to delete cert: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
{ type: 'danger', confirmText: 'Delete' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function regenerateCert(domain) {
|
function regenerateCert(domain) {
|
||||||
if (window.showConfirm) {
|
if (!window.showConfirm) return;
|
||||||
window.showConfirm(`Regenerate certificate for ${domain}?`, async () => {
|
window.showConfirm(
|
||||||
|
`Regenerate the certificate for ${domain}?`,
|
||||||
|
async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/regenerate-cert', {
|
const response = await fetch('/api/regenerate-cert', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -95,8 +113,9 @@ function regenerateCert(domain) {
|
|||||||
console.error('Failed to regenerate cert:', err);
|
console.error('Failed to regenerate cert:', err);
|
||||||
if (window.showNotification) window.showNotification('Failed to regenerate cert: ' + err.message, 'error');
|
if (window.showNotification) window.showNotification('Failed to regenerate cert: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
}
|
{ type: 'warning', confirmText: 'Regenerate' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showCertDetails(domain) {
|
async function showCertDetails(domain) {
|
||||||
@@ -108,8 +127,10 @@ async function showCertDetails(domain) {
|
|||||||
const data = await res.text();
|
const data = await res.text();
|
||||||
const formatted = formatCertificate(data);
|
const formatted = formatCertificate(data);
|
||||||
const contentEl = document.getElementById('cert-details-content');
|
const contentEl = document.getElementById('cert-details-content');
|
||||||
|
const domainEl = document.getElementById('cert-details-domain');
|
||||||
const modal = document.getElementById('certDetailsModal');
|
const modal = document.getElementById('certDetailsModal');
|
||||||
if (contentEl) contentEl.textContent = formatted;
|
if (contentEl) contentEl.textContent = formatted;
|
||||||
|
if (domainEl) domainEl.textContent = domain;
|
||||||
if (modal) modal.showModal();
|
if (modal) modal.showModal();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch cert details:', err);
|
console.error('Failed to fetch cert details:', err);
|
||||||
@@ -143,4 +164,3 @@ window.regenerateCert = regenerateCert;
|
|||||||
window.showCertDetails = showCertDetails;
|
window.showCertDetails = showCertDetails;
|
||||||
window.formatCertificate = formatCertificate;
|
window.formatCertificate = formatCertificate;
|
||||||
window.copyCertDetails = copyCertDetails;
|
window.copyCertDetails = copyCertDetails;
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,36 @@ function getConsensusStatusBadge(status) {
|
|||||||
// Make function globally available
|
// Make function globally available
|
||||||
window.getConsensusStatusBadge = getConsensusStatusBadge;
|
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 = {
|
window.chartColors = {
|
||||||
primary: 'rgb(59, 130, 246)',
|
primary: 'rgb(59, 130, 246)',
|
||||||
success: 'rgb(34, 197, 94)',
|
success: 'rgb(34, 197, 94)',
|
||||||
@@ -102,7 +132,7 @@ window.tabs = {
|
|||||||
consensusInfo = `<td class="p-3">${getConsensusStatusBadge('unknown')}</td>`;
|
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>
|
<td class="p-3 break-all">${item.hash}</td>
|
||||||
${consensusInfo}
|
${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>`;
|
<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',
|
filteredKey: 'filteredCerts',
|
||||||
containerId: 'certsList',
|
containerId: 'certsList',
|
||||||
paginationId: 'certsPagination',
|
paginationId: 'certsPagination',
|
||||||
|
sentinelId: 'certsScrollSentinel',
|
||||||
sort: (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }),
|
sort: (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }),
|
||||||
filter: (item, query) => item.toLowerCase().includes(query),
|
filter: (item, query) => item.toLowerCase().includes(query),
|
||||||
renderItem: (cert) => {
|
renderItem: (cert) => {
|
||||||
const li = document.createElement('li');
|
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.className = 'cert-row';
|
||||||
li.innerHTML = `<span class="cursor-pointer flex-1 break-all" onclick="showCertDetails('${cert}')">${cert}</span>
|
const esc = window.escapeHtml || ((value) => String(value));
|
||||||
<div>
|
const domainAttr = String(cert).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
<button onclick="deleteCert('${cert}')" class="px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 mr-2">Delete</button>
|
li.innerHTML = `
|
||||||
<button onclick="regenerateCert('${cert}')" class="px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600">Regenerate</button>
|
<div class="cert-row-main">
|
||||||
</div>`;
|
<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;
|
return li;
|
||||||
|
},
|
||||||
|
preRender: () => {
|
||||||
|
updateCertsListChrome();
|
||||||
|
},
|
||||||
|
onAllItemsLoaded: () => {
|
||||||
|
showCertsListEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
interfaces: {
|
interfaces: {
|
||||||
@@ -223,14 +271,72 @@ window.tabs = {
|
|||||||
filteredKey: 'filteredInterfaces',
|
filteredKey: 'filteredInterfaces',
|
||||||
containerId: 'interfacesTable',
|
containerId: 'interfacesTable',
|
||||||
paginationId: 'interfacesPagination',
|
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' }),
|
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) => {
|
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');
|
const tr = document.createElement('tr');
|
||||||
tr.className = 'border-b hover:bg-gray-50 dark:hover:bg-gray-700';
|
tr.className = 'iface-row';
|
||||||
tr.innerHTML = `<td class="p-3">${item.domain}</td>
|
tr.innerHTML = `
|
||||||
<td class="p-3">${item.ip}</td>`;
|
<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;
|
return tr;
|
||||||
|
},
|
||||||
|
preRender: (visibleCount) => {
|
||||||
|
if (window.updateInterfacesChrome) window.updateInterfacesChrome(visibleCount);
|
||||||
|
},
|
||||||
|
onAllItemsLoaded: () => {
|
||||||
|
if (window.showInterfacesListEnd) window.showInterfacesListEnd();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'local-dns': {
|
'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">
|
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>
|
<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>`,
|
</svg>`,
|
||||||
confirmButtonClass: 'bg-yellow-glass'
|
confirmButtonClass: 'admin-btn--warning'
|
||||||
},
|
},
|
||||||
danger: {
|
danger: {
|
||||||
title: 'Danger',
|
title: 'Danger',
|
||||||
icon: `<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<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>`,
|
</svg>`,
|
||||||
confirmButtonClass: 'bg-red-glass'
|
confirmButtonClass: 'admin-btn--danger'
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
title: 'Information',
|
title: 'Information',
|
||||||
icon: `<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<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>`,
|
</svg>`,
|
||||||
confirmButtonClass: 'bg-blue-glass'
|
confirmButtonClass: 'admin-btn--primary'
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
icon: `<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<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>`,
|
</svg>`,
|
||||||
confirmButtonClass: 'bg-green-glass'
|
confirmButtonClass: 'admin-btn--success'
|
||||||
},
|
},
|
||||||
default: {
|
default: {
|
||||||
title: 'Confirm Action',
|
title: 'Confirm Action',
|
||||||
@@ -187,22 +187,19 @@ const ConfirmationModal = {
|
|||||||
modal.className = 'confirmation-modal p-0 bg-transparent border-0 outline-none rounded-lg shadow-2xl w-full max-w-md';
|
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.setAttribute('style', 'border: none; outline: none; padding: 0; margin: 0; background: transparent;');
|
||||||
modal.innerHTML = `
|
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 class="modal-content confirmation-modal-panel ${this.defaults.width} mx-auto">
|
||||||
<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 class="modal-header p-6 pb-4">
|
||||||
<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="flex items-center gap-3">
|
||||||
<div class="modal-icon flex-shrink-0"></div>
|
<div class="modal-icon flex-shrink-0"></div>
|
||||||
<h3 class="modal-title text-xl font-bold flex-1" style="color: var(--text-primary);"></h3>
|
<h3 class="modal-title text-lg font-semibold flex-1"></h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body p-6">
|
<div class="modal-body p-6 pt-0">
|
||||||
<div class="modal-message" style="color: var(--text-secondary);"></div>
|
<div class="modal-message text-sm theme-text-secondary"></div>
|
||||||
</div>
|
|
||||||
<div class="modal-footer p-6 pt-4 flex justify-end gap-3" style="border-top: 1px solid var(--border-color);">
|
|
||||||
<button data-cancel-btn class="px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none"></button>
|
|
||||||
<button data-confirm-btn class="px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none"></button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div 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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -243,74 +240,19 @@ const ConfirmationModal = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update buttons with glass styling
|
// Update buttons
|
||||||
if (confirmBtn) {
|
if (confirmBtn) {
|
||||||
confirmBtn.textContent = config.confirmText;
|
confirmBtn.textContent = config.confirmText;
|
||||||
confirmBtn.className = 'px-4 py-2 rounded-lg font-medium transition-all focus:outline-none btn-glass-primary';
|
const confirmVariant = config.confirmButtonClass || 'admin-btn--primary';
|
||||||
// Apply type-specific colors
|
confirmBtn.className = `admin-btn ${confirmVariant}`;
|
||||||
let bgColor, borderColor;
|
confirmBtn.style.cssText = '';
|
||||||
if (config.type === 'warning') {
|
|
||||||
bgColor = 'rgba(245, 158, 11, 0.3)';
|
|
||||||
borderColor = 'rgba(245, 158, 11, 0.5)';
|
|
||||||
} else if (config.type === 'danger') {
|
|
||||||
bgColor = 'rgba(239, 68, 68, 0.3)';
|
|
||||||
borderColor = 'rgba(239, 68, 68, 0.5)';
|
|
||||||
} else if (config.type === 'info') {
|
|
||||||
bgColor = 'rgba(59, 130, 246, 0.3)';
|
|
||||||
borderColor = 'rgba(59, 130, 246, 0.5)';
|
|
||||||
} else if (config.type === 'success') {
|
|
||||||
bgColor = 'rgba(16, 185, 129, 0.3)';
|
|
||||||
borderColor = 'rgba(16, 185, 129, 0.5)';
|
|
||||||
} else {
|
|
||||||
bgColor = 'rgba(99, 102, 241, 0.3)';
|
|
||||||
borderColor = 'rgba(99, 102, 241, 0.5)';
|
|
||||||
}
|
|
||||||
confirmBtn.style.cssText = `
|
|
||||||
background: ${bgColor};
|
|
||||||
backdrop-filter: blur(20px) saturate(180%);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
||||||
border: 1px solid ${borderColor};
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: 0 4px 6px -1px ${borderColor.replace('0.5', '0.2')}, 0 2px 4px -1px ${borderColor.replace('0.5', '0.1')}, inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
`;
|
|
||||||
confirmBtn.addEventListener('mouseenter', function() {
|
|
||||||
this.style.background = bgColor.replace('0.3', '0.5');
|
|
||||||
this.style.borderColor = borderColor.replace('0.5', '0.7');
|
|
||||||
this.style.transform = 'translateY(-2px)';
|
|
||||||
});
|
|
||||||
confirmBtn.addEventListener('mouseleave', function() {
|
|
||||||
this.style.background = bgColor;
|
|
||||||
this.style.borderColor = borderColor;
|
|
||||||
this.style.transform = 'translateY(0)';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelBtn) {
|
if (cancelBtn) {
|
||||||
if (config.showCancel) {
|
if (config.showCancel) {
|
||||||
cancelBtn.textContent = config.cancelText;
|
cancelBtn.textContent = config.cancelText;
|
||||||
cancelBtn.className = 'px-4 py-2 rounded-lg font-medium transition-all focus:outline-none btn-glass';
|
cancelBtn.className = 'admin-btn admin-btn--secondary';
|
||||||
cancelBtn.style.cssText = `
|
cancelBtn.style.cssText = '';
|
||||||
background: var(--bg-glass);
|
|
||||||
backdrop-filter: blur(20px) saturate(180%);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
`;
|
|
||||||
cancelBtn.addEventListener('mouseenter', function() {
|
|
||||||
this.style.background = 'var(--bg-glass-hover)';
|
|
||||||
this.style.borderColor = 'var(--border-color-strong)';
|
|
||||||
this.style.boxShadow = 'var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.08)';
|
|
||||||
});
|
|
||||||
cancelBtn.addEventListener('mouseleave', function() {
|
|
||||||
this.style.background = 'var(--bg-glass)';
|
|
||||||
this.style.borderColor = 'var(--border-color)';
|
|
||||||
this.style.boxShadow = 'var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03)';
|
|
||||||
});
|
|
||||||
cancelBtn.classList.remove('hidden');
|
cancelBtn.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
cancelBtn.classList.add('hidden');
|
cancelBtn.classList.add('hidden');
|
||||||
|
|||||||
@@ -234,6 +234,9 @@ function loadNextBatch(tabId, data) {
|
|||||||
// Remove sentinel if exists (but keep config sentinel)
|
// Remove sentinel if exists (but keep config sentinel)
|
||||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
if (sentinel && !config.sentinelId) sentinel.remove();
|
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||||
|
if (start >= data.length && data.length > 0 && config.onAllItemsLoaded) {
|
||||||
|
config.onAllItemsLoaded(data.length);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +260,9 @@ function loadNextBatch(tabId, data) {
|
|||||||
// Remove sentinel if exists (but keep config sentinel)
|
// Remove sentinel if exists (but keep config sentinel)
|
||||||
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
const sentinel = container.querySelector('.infinite-scroll-sentinel');
|
||||||
if (sentinel && !config.sentinelId) sentinel.remove();
|
if (sentinel && !config.sentinelId) sentinel.remove();
|
||||||
|
if (config.onAllItemsLoaded) {
|
||||||
|
config.onAllItemsLoaded(data.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -734,7 +734,7 @@ async function cleanDnsPassStorage() {
|
|||||||
// Restore button
|
// Restore button
|
||||||
if (buttonEl) {
|
if (buttonEl) {
|
||||||
buttonEl.disabled = false;
|
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;
|
let healthUpdateInterval = null;
|
||||||
|
|
||||||
const SERVICE_DEFS = [
|
const SERVICE_DEFS = [
|
||||||
{ key: 'dns', name: 'DNS Service', icon: '🌐' },
|
{ key: 'dns', name: 'DNS Service', icon: 'fa-globe' },
|
||||||
{ key: 'proxy', name: 'Proxy Service', icon: '🔒' },
|
{ key: 'proxy', name: 'Proxy Service', icon: 'fa-shield-halved' },
|
||||||
{ key: 'swarm', name: 'Swarm', icon: '🔗' },
|
{ key: 'swarm', name: 'Swarm', icon: 'fa-diagram-project' },
|
||||||
{ key: 'corestore', name: 'Corestore', icon: '💾' }
|
{ key: 'corestore', name: 'Corestore', icon: 'fa-hard-drive' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fetch health data
|
// Fetch health data
|
||||||
@@ -121,16 +121,16 @@ function ensureServiceCards(container) {
|
|||||||
|
|
||||||
container.dataset.initialized = '1';
|
container.dataset.initialized = '1';
|
||||||
container.innerHTML = SERVICE_DEFS.map((service) => `
|
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="admin-card theme-card flex flex-col health-service-card" data-service="${service.key}">
|
||||||
<div class="flex items-center justify-between mb-2 gap-2">
|
<div class="flex items-center justify-between mb-3 gap-2">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
<span class="text-2xl shrink-0" aria-hidden="true">${service.icon}</span>
|
<span class="health-service-icon" aria-hidden="true"><i class="fas ${service.icon}"></i></span>
|
||||||
<h3 class="text-lg font-semibold truncate">${service.name}</h3>
|
<h3 class="text-sm font-semibold truncate">${service.name}</h3>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p class="health-service-enabled text-sm text-gray-600 dark:text-gray-400 mb-2">-</p>
|
<p class="health-service-enabled text-xs theme-text-tertiary 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>
|
<dl class="health-service-details text-xs theme-text-tertiary space-y-1 min-h-[3rem] tabular-nums"></dl>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
@@ -147,13 +147,10 @@ function updateServiceCardElement(card, serviceData) {
|
|||||||
if (badge.textContent !== statusText) {
|
if (badge.textContent !== statusText) {
|
||||||
badge.textContent = statusText;
|
badge.textContent = statusText;
|
||||||
}
|
}
|
||||||
const nextClass = `health-service-badge px-3 py-1 rounded-full text-sm font-semibold shrink-0 ${
|
const nextClass = `health-service-badge health-service-badge--${healthy ? 'ok' : 'error'}`;
|
||||||
healthy ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
}`;
|
|
||||||
if (badge.className !== nextClass) {
|
if (badge.className !== nextClass) {
|
||||||
badge.className = nextClass;
|
badge.className = nextClass;
|
||||||
}
|
}
|
||||||
badge.style.color = 'var(--text-primary)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledEl = card.querySelector('.health-service-enabled');
|
const enabledEl = card.querySelector('.health-service-enabled');
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ const infoContent = {
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: 'Overview',
|
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',
|
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',
|
title: 'Adding Domains',
|
||||||
@@ -190,15 +190,23 @@ const infoContent = {
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: 'Overview',
|
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',
|
title: 'Domain mappings',
|
||||||
content: 'Each domain gets assigned a virtual IP address on a virtual interface. This allows local applications to connect to P2P domains.'
|
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',
|
title: 'Subnets & capacity',
|
||||||
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.'
|
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
|
// Virtual interfaces tab — summary chrome, cleanup, copy IP
|
||||||
function cleanupInterfaces() {
|
|
||||||
|
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) {
|
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 {
|
try {
|
||||||
const response = await fetch('/api/cleanup-interfaces', { method: 'POST' });
|
const response = await fetch('/api/cleanup-interfaces', { method: 'POST' });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -11,11 +242,18 @@ function cleanupInterfaces() {
|
|||||||
if (window.genericFetch) window.genericFetch('interfaces', true);
|
if (window.genericFetch) window.genericFetch('interfaces', true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to cleanup interfaces:', 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;
|
window.cleanupInterfaces = cleanupInterfaces;
|
||||||
|
|
||||||
|
|||||||
@@ -191,13 +191,10 @@ function renderPluginCard(plugin) {
|
|||||||
${plugin.actions.map(action => `
|
${plugin.actions.map(action => `
|
||||||
<button
|
<button
|
||||||
id="action-${plugin.domain}-${action.name}"
|
id="action-${plugin.domain}-${action.name}"
|
||||||
class="px-3 py-1 text-sm rounded transition-colors"
|
class="admin-btn admin-btn--primary admin-btn--sm"
|
||||||
style="background: rgba(59, 130, 246, 0.3); border: 1px solid rgba(59, 130, 246, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
|
||||||
onmouseover="this.style.background='rgba(59, 130, 246, 0.5)'; this.style.borderColor='rgba(59, 130, 246, 0.7)'"
|
|
||||||
onmouseout="this.style.background='rgba(59, 130, 246, 0.3)'; this.style.borderColor='rgba(59, 130, 246, 0.5)'"
|
|
||||||
title="${action.description || action.label}"
|
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>
|
</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,10 +213,7 @@ function renderPluginCard(plugin) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick="savePluginSettings('${plugin.domain}')"
|
onclick="savePluginSettings('${plugin.domain}')"
|
||||||
class="mt-3 px-4 py-2 text-sm rounded transition-colors"
|
class="admin-btn admin-btn--success admin-btn--sm mt-3"
|
||||||
style="background: rgba(34, 197, 94, 0.3); border: 1px solid rgba(34, 197, 94, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
|
||||||
onmouseover="this.style.background='rgba(34, 197, 94, 0.5)'; this.style.borderColor='rgba(34, 197, 94, 0.7)'"
|
|
||||||
onmouseout="this.style.background='rgba(34, 197, 94, 0.3)'; this.style.borderColor='rgba(34, 197, 94, 0.5)'"
|
|
||||||
>
|
>
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -228,15 +222,15 @@ function renderPluginCard(plugin) {
|
|||||||
: '<p class="text-sm theme-text-tertiary mt-4">No settings registered</p>';
|
: '<p class="text-sm theme-text-tertiary mt-4">No settings registered</p>';
|
||||||
|
|
||||||
const statusBadge = plugin.status === 'loaded'
|
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'
|
: plugin.status === 'stopped'
|
||||||
? '<span class="px-2 py-1 bg-red-500 rounded text-xs" style="color: var(--text-primary);">Stopped</span>'
|
? '<span class="plugin-status-badge plugin-status-badge--stopped">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--static">Static</span>';
|
||||||
|
|
||||||
const featuresHtml = [
|
const featuresHtml = [
|
||||||
plugin.hasHandler ? '<span class="text-xs bg-blue-500 px-2 py-1 rounded" style="color: var(--text-primary);">Handler</span>' : '',
|
plugin.hasHandler ? '<span class="plugin-feature-tag">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.hasWww ? '<span class="plugin-feature-tag">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.hasDatabase ? '<span class="plugin-feature-tag">Database</span>' : ''
|
||||||
].filter(Boolean).join('');
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
const isLoading = pluginLoadingStates.get(plugin.domain) || false;
|
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">
|
<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>` : ''}
|
${plugin.icon ? `<i class="fa-solid fa-${escapeHtml(plugin.icon)}"></i>` : ''}
|
||||||
${escapeHtml(plugin.name)}
|
${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>
|
</h3>
|
||||||
<div class="ml-2">
|
<div class="ml-2">
|
||||||
${statusBadge}
|
${statusBadge}
|
||||||
@@ -263,15 +257,13 @@ function renderPluginCard(plugin) {
|
|||||||
${featuresHtml ? `<div class="flex gap-2 mt-2">${featuresHtml}</div>` : ''}
|
${featuresHtml ? `<div class="flex gap-2 mt-2">${featuresHtml}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 items-end">
|
<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>
|
<span class="text-xs font-semibold theme-text-secondary uppercase tracking-wide">Status</span>
|
||||||
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? `
|
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? `
|
||||||
<div class="flex items-center gap-2">
|
<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="plugin-toggle-switch plugin-toggle-switch--on plugin-toggle-switch--locked"></div>
|
||||||
<div class="w-5 h-5 rounded-full" style="background: var(--text-primary); border: 1px solid var(--border-color);"></div>
|
|
||||||
</div>
|
|
||||||
<span class="ml-3 text-sm font-medium theme-text-primary min-w-[70px]">
|
<span 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 class="ml-2 text-xs theme-text-tertiary">(System)</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,9 +277,9 @@ function renderPluginCard(plugin) {
|
|||||||
onchange="togglePluginEnabled('${plugin.domain}', this.checked)"
|
onchange="togglePluginEnabled('${plugin.domain}', this.checked)"
|
||||||
id="toggle-${plugin.domain}"
|
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">
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
`)}
|
`)}
|
||||||
@@ -296,33 +288,27 @@ function renderPluginCard(plugin) {
|
|||||||
${plugin.status === 'loaded' ? `
|
${plugin.status === 'loaded' ? `
|
||||||
<button
|
<button
|
||||||
onclick="reloadPlugin('${plugin.domain}')"
|
onclick="reloadPlugin('${plugin.domain}')"
|
||||||
class="px-4 py-2 rounded transition-colors flex items-center gap-2"
|
class="admin-btn admin-btn--warning admin-btn--sm"
|
||||||
style="background: rgba(234, 179, 8, 0.3); border: 1px solid rgba(234, 179, 8, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
|
||||||
onmouseover="this.style.background='rgba(234, 179, 8, 0.5)'; this.style.borderColor='rgba(234, 179, 8, 0.7)'"
|
|
||||||
onmouseout="this.style.background='rgba(234, 179, 8, 0.3)'; this.style.borderColor='rgba(234, 179, 8, 0.5)'"
|
|
||||||
title="Reload this plugin without restarting P2NS"
|
title="Reload this plugin without restarting P2NS"
|
||||||
>
|
>
|
||||||
🔄 Restart
|
<i class="fas fa-rotate-right" aria-hidden="true"></i> Restart
|
||||||
</button>
|
</button>
|
||||||
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? '' : `
|
${(['p2ns.admin', 'global.profile'].includes(plugin.domain) ? '' : `
|
||||||
<button
|
<button
|
||||||
onclick="stopPlugin('${plugin.domain}')"
|
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)"
|
title="Stop this plugin (unload it from memory)"
|
||||||
>
|
>
|
||||||
⏹️ Stop
|
<i class="fas fa-stop" aria-hidden="true"></i> Stop
|
||||||
</button>
|
</button>
|
||||||
`)}
|
`)}
|
||||||
` : plugin.enabled !== false ? `
|
` : plugin.enabled !== false ? `
|
||||||
<button
|
<button
|
||||||
onclick="startPlugin('${plugin.domain}')"
|
onclick="startPlugin('${plugin.domain}')"
|
||||||
class="px-4 py-2 rounded transition-colors flex items-center gap-2"
|
class="admin-btn admin-btn--success admin-btn--sm"
|
||||||
style="background: rgba(34, 197, 94, 0.3); border: 1px solid rgba(34, 197, 94, 0.5); color: var(--text-primary); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);"
|
|
||||||
onmouseover="this.style.background='rgba(34, 197, 94, 0.5)'; this.style.borderColor='rgba(34, 197, 94, 0.7)'"
|
|
||||||
onmouseout="this.style.background='rgba(34, 197, 94, 0.3)'; this.style.borderColor='rgba(34, 197, 94, 0.5)'"
|
|
||||||
title="Start this plugin (load it into memory)"
|
title="Start this plugin (load it into memory)"
|
||||||
>
|
>
|
||||||
▶️ Start
|
<i class="fas fa-play" aria-hidden="true"></i> Start
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -335,9 +321,9 @@ function renderPluginCard(plugin) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
${plugin.status === 'stopped' ? `
|
${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%);">
|
<div class="mt-4 p-3 rounded-lg plugin-alert-banner">
|
||||||
<p class="text-sm" style="color: var(--text-primary);">
|
<p class="text-sm theme-text-secondary">
|
||||||
⚠️ This plugin is currently stopped. Actions and settings are not available until it is started.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
@@ -566,7 +552,7 @@ async function executeAction(domain, actionName, action) {
|
|||||||
if (params === null) {
|
if (params === null) {
|
||||||
if (button) {
|
if (button) {
|
||||||
button.disabled = false;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -593,7 +579,7 @@ async function executeAction(domain, actionName, action) {
|
|||||||
|
|
||||||
if (button) {
|
if (button) {
|
||||||
button.disabled = false;
|
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) {
|
} catch (err) {
|
||||||
console.error('Error executing action:', err);
|
console.error('Error executing action:', err);
|
||||||
@@ -605,7 +591,7 @@ async function executeAction(domain, actionName, action) {
|
|||||||
const button = document.getElementById(buttonId);
|
const button = document.getElementById(buttonId);
|
||||||
if (button && action) {
|
if (button && action) {
|
||||||
button.disabled = false;
|
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) {
|
if (result.restartRequired) {
|
||||||
const settingsList = result.restartRequiredSettings.join(', ');
|
const settingsList = result.restartRequiredSettings.join(', ');
|
||||||
|
if (window.setPendingRestartSettings) {
|
||||||
|
window.setPendingRestartSettings(result.restartRequiredSettings || []);
|
||||||
|
}
|
||||||
if (window.showNotification) {
|
if (window.showNotification) {
|
||||||
window.showNotification(`Settings saved. The following settings require restart to take effect: ${settingsList}. Other settings have been applied live.`, 'warning');
|
window.showNotification(`Settings saved. The following settings require restart to take effect: ${settingsList}. Other settings have been applied live.`, 'warning');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (window.setPendingRestartSettings) {
|
||||||
|
window.setPendingRestartSettings([]);
|
||||||
|
}
|
||||||
if (window.showNotification) {
|
if (window.showNotification) {
|
||||||
window.showNotification('Settings saved and applied successfully (no restart required).', 'success');
|
window.showNotification('Settings saved and applied successfully (no restart required).', 'success');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ function connectWebSocket() {
|
|||||||
window.wsConnected = true;
|
window.wsConnected = true;
|
||||||
window.wsReconnectAttempts = 0;
|
window.wsReconnectAttempts = 0;
|
||||||
stopPollingFallback();
|
stopPollingFallback();
|
||||||
|
if (window.stopInterfacesPollingFallback) {
|
||||||
|
window.stopInterfacesPollingFallback();
|
||||||
|
}
|
||||||
if (window.stopStatsPollingFallback) {
|
if (window.stopStatsPollingFallback) {
|
||||||
window.stopStatsPollingFallback();
|
window.stopStatsPollingFallback();
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,15 @@ function connectWebSocket() {
|
|||||||
if (window.startDomainsUpdates) {
|
if (window.startDomainsUpdates) {
|
||||||
window.startDomainsUpdates();
|
window.startDomainsUpdates();
|
||||||
}
|
}
|
||||||
|
if (window.startInterfacesUpdates) {
|
||||||
|
window.startInterfacesUpdates();
|
||||||
|
}
|
||||||
|
if (window.scheduleAdminAlertsRefresh) {
|
||||||
|
window.scheduleAdminAlertsRefresh();
|
||||||
|
}
|
||||||
|
if (window.syncClientAdminAlerts) {
|
||||||
|
window.syncClientAdminAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
// Request domains list from server
|
// Request domains list from server
|
||||||
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -85,6 +97,12 @@ function connectWebSocket() {
|
|||||||
if (window.stopDomainsUpdates) {
|
if (window.stopDomainsUpdates) {
|
||||||
window.stopDomainsUpdates();
|
window.stopDomainsUpdates();
|
||||||
}
|
}
|
||||||
|
if (window.stopInterfacesUpdates) {
|
||||||
|
window.stopInterfacesUpdates();
|
||||||
|
}
|
||||||
|
if (window.syncClientAdminAlerts) {
|
||||||
|
window.syncClientAdminAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
// Clear domains data and show disconnected state when WebSocket disconnects
|
// Clear domains data and show disconnected state when WebSocket disconnects
|
||||||
window.domainsData = [];
|
window.domainsData = [];
|
||||||
@@ -106,6 +124,9 @@ function connectWebSocket() {
|
|||||||
if (window.activeTab === 'host') {
|
if (window.activeTab === 'host') {
|
||||||
startPollingFallback();
|
startPollingFallback();
|
||||||
}
|
}
|
||||||
|
if (window.activeTab === 'interfaces') {
|
||||||
|
startInterfacesPollingFallback();
|
||||||
|
}
|
||||||
if (window.activeTab === 'stats' && window.startStatsPollingFallback) {
|
if (window.activeTab === 'stats' && window.startStatsPollingFallback) {
|
||||||
window.startStatsPollingFallback();
|
window.startStatsPollingFallback();
|
||||||
}
|
}
|
||||||
@@ -117,6 +138,9 @@ function connectWebSocket() {
|
|||||||
if (window.activeTab === 'host') {
|
if (window.activeTab === 'host') {
|
||||||
startPollingFallback();
|
startPollingFallback();
|
||||||
}
|
}
|
||||||
|
if (window.activeTab === 'interfaces') {
|
||||||
|
startInterfacesPollingFallback();
|
||||||
|
}
|
||||||
if (window.activeTab === 'stats' && window.startStatsPollingFallback) {
|
if (window.activeTab === 'stats' && window.startStatsPollingFallback) {
|
||||||
window.startStatsPollingFallback();
|
window.startStatsPollingFallback();
|
||||||
}
|
}
|
||||||
@@ -200,6 +224,7 @@ function connectWebSocket() {
|
|||||||
if (window.activeTab === 'stats' && window.applyHealthPayload) {
|
if (window.activeTab === 'stats' && window.applyHealthPayload) {
|
||||||
window.applyHealthPayload(data);
|
window.applyHealthPayload(data);
|
||||||
}
|
}
|
||||||
|
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.type === 'update-plugins' && window.activeTab === 'plugins') {
|
if (data.type === 'update-plugins' && window.activeTab === 'plugins') {
|
||||||
@@ -215,6 +240,13 @@ function connectWebSocket() {
|
|||||||
if (window.genericFetch) window.genericFetch('settings', true);
|
if (window.genericFetch) window.genericFetch('settings', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.type === 'interfaces-list') {
|
||||||
|
if (window.applyInterfacesSnapshot) {
|
||||||
|
window.applyInterfacesSnapshot(data);
|
||||||
|
}
|
||||||
|
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type === 'domains-list') {
|
if (data.type === 'domains-list') {
|
||||||
// Update domains data directly from WebSocket
|
// Update domains data directly from WebSocket
|
||||||
if (data.domains) {
|
if (data.domains) {
|
||||||
@@ -245,6 +277,7 @@ function connectWebSocket() {
|
|||||||
window.genericFilter('domains');
|
window.genericFilter('domains');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -261,6 +294,7 @@ function connectWebSocket() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (window.scheduleAdminAlertsRefresh) window.scheduleAdminAlertsRefresh();
|
||||||
} else if (data.type === 'log-snapshot' && window.applyLogSnapshot) {
|
} else if (data.type === 'log-snapshot' && window.applyLogSnapshot) {
|
||||||
window.applyLogSnapshot(data);
|
window.applyLogSnapshot(data);
|
||||||
} else if (data.type === 'file-log' && window.applyFileLog) {
|
} else if (data.type === 'file-log' && window.applyFileLog) {
|
||||||
@@ -336,31 +370,34 @@ function applyStatusPayload(data) {
|
|||||||
if (!data) return;
|
if (!data) return;
|
||||||
window.latestStatusPayload = data;
|
window.latestStatusPayload = data;
|
||||||
let text;
|
let text;
|
||||||
let color = 'bg-blue-600';
|
let statusClass = 'status-indicator--ok';
|
||||||
if (data.isShuttingDown) {
|
if (data.isShuttingDown) {
|
||||||
text = 'Gracefully Cleaning....';
|
text = 'Gracefully cleaning…';
|
||||||
color = 'bg-orange-500';
|
statusClass = 'status-indicator--shutdown';
|
||||||
} else if (data.isMaster) {
|
} 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) {
|
} else if (data.isConnected) {
|
||||||
text = `Connected to Master • Peers: ${data.peersCount}`;
|
text = `Connected to Master · Peers: ${data.peersCount}`;
|
||||||
color = 'bg-blue-500';
|
statusClass = 'status-indicator--ok';
|
||||||
} else if (data.peersCount > 0) {
|
} else if (data.peersCount > 0) {
|
||||||
text = 'Requesting access...';
|
text = 'Requesting access…';
|
||||||
color = 'bg-blue-300';
|
statusClass = 'status-indicator--pending';
|
||||||
} else {
|
} else {
|
||||||
text = 'Searching for peers...';
|
text = 'Searching for peers…';
|
||||||
color = 'bg-blue-300';
|
statusClass = 'status-indicator--searching';
|
||||||
}
|
}
|
||||||
if (!window.wsConnected) {
|
if (!window.wsConnected) {
|
||||||
text += ' (Polling)';
|
text += ' (Polling)';
|
||||||
color = 'bg-yellow-500';
|
statusClass = 'status-indicator--polling';
|
||||||
}
|
}
|
||||||
const indicator = document.getElementById('status-indicator');
|
const indicator = document.getElementById('status-indicator');
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.textContent = text;
|
indicator.textContent = text;
|
||||||
indicator.className = `px-4 py-2 rounded-lg status-indicator-glass ${color}`;
|
indicator.className = `admin-status-indicator ${statusClass}`;
|
||||||
indicator.style.color = 'var(--text-primary)';
|
}
|
||||||
|
if (window.syncClientAdminAlerts) {
|
||||||
|
window.syncClientAdminAlerts();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,8 +417,7 @@ async function updateStatus() {
|
|||||||
const indicator = document.getElementById('status-indicator');
|
const indicator = document.getElementById('status-indicator');
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.textContent = 'Status unknown';
|
indicator.textContent = 'Status unknown';
|
||||||
indicator.className = 'px-4 py-2 rounded-lg status-indicator-glass bg-blue-900';
|
indicator.className = 'admin-status-indicator status-indicator--unknown';
|
||||||
indicator.style.color = 'var(--text-primary)';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
async function handleRefresh() {
|
||||||
if (window.ConfirmationModal) {
|
if (window.ConfirmationModal) {
|
||||||
const confirmed = await window.ConfirmationModal.warning(
|
const confirmed = await window.ConfirmationModal.warning(
|
||||||
@@ -491,6 +562,10 @@ window.startStatusUpdates = startStatusUpdates;
|
|||||||
window.stopStatusUpdates = stopStatusUpdates;
|
window.stopStatusUpdates = stopStatusUpdates;
|
||||||
window.startDomainsUpdates = startDomainsUpdates;
|
window.startDomainsUpdates = startDomainsUpdates;
|
||||||
window.stopDomainsUpdates = stopDomainsUpdates;
|
window.stopDomainsUpdates = stopDomainsUpdates;
|
||||||
|
window.startInterfacesUpdates = startInterfacesUpdates;
|
||||||
|
window.stopInterfacesUpdates = stopInterfacesUpdates;
|
||||||
|
window.startInterfacesPollingFallback = startInterfacesPollingFallback;
|
||||||
|
window.stopInterfacesPollingFallback = stopInterfacesPollingFallback;
|
||||||
window.handleRefresh = handleRefresh;
|
window.handleRefresh = handleRefresh;
|
||||||
|
|
||||||
// Initialize WebSocket connection
|
// 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';
|
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() {
|
async function loadHolesailClients() {
|
||||||
state.holesailClientChildren = new Map();
|
state.holesailClientChildren = new Map();
|
||||||
state.holesailClientOpts = new Map();
|
state.holesailClientOpts = new Map();
|
||||||
@@ -144,6 +166,7 @@ async function saveHolesailClients() {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
loadHolesailClients,
|
loadHolesailClients,
|
||||||
startForkedHolesailClient,
|
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 { createInterfaceForDomain } = require('../../networking/virtual_interfaces');
|
||||||
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
const { logDebug, logError, logInfo, logWarn } = require('../../infrastructure/logger');
|
||||||
const { startHolesailServer, saveHolesailServers } = require('../holesail-servers');
|
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 { ensurePortFree } = require('../port-management');
|
||||||
const { broadcast } = require('../websocket');
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
@@ -38,19 +38,8 @@ async function handleHolesailRoutes(req, res) {
|
|||||||
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
if (method === 'GET' && urlPath === '/api/holesail-clients') {
|
||||||
try {
|
try {
|
||||||
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
const clients = Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||||
const child = state.holesailClientChildren.get(id);
|
|
||||||
const info = state.holesailClientInfos.get(id) || {};
|
const info = state.holesailClientInfos.get(id) || {};
|
||||||
const key = `${opts.domain}:${opts.port}`;
|
const status = getHolesailClientStatus(id, opts, info);
|
||||||
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';
|
|
||||||
}
|
|
||||||
return { id, opts, info: { ...info, state: status } };
|
return { id, opts, info: { ...info, state: status } };
|
||||||
});
|
});
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
const state = require('../../infrastructure/state');
|
|
||||||
const { cleanupInterfaces } = require('../../maintenance/cleanup');
|
const { cleanupInterfaces } = require('../../maintenance/cleanup');
|
||||||
const { logError } = require('../../infrastructure/logger');
|
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) {
|
async function handleInterfacesRoutes(req, res) {
|
||||||
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
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') {
|
if (method === 'GET' && urlPath === '/api/interfaces') {
|
||||||
try {
|
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.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(interfaces));
|
res.end(JSON.stringify(payload));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
logError('Admin', `Failed to fetch interfaces: ${err.message}`);
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
@@ -20,10 +35,25 @@ async function handleInterfacesRoutes(req, res) {
|
|||||||
return true;
|
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') {
|
if (method === 'POST' && urlPath === '/api/cleanup-interfaces') {
|
||||||
try {
|
try {
|
||||||
await cleanupInterfaces();
|
await cleanupInterfaces();
|
||||||
broadcast({ type: 'update-interfaces' });
|
scheduleInterfacesBroadcast();
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
res.end('OK');
|
res.end('OK');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -38,4 +68,3 @@ async function handleInterfacesRoutes(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handleInterfacesRoutes };
|
module.exports = { handleInterfacesRoutes };
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,12 @@ async function atomicDomainCleanup(domain) {
|
|||||||
await removeVirtualInterface(ip);
|
await removeVirtualInterface(ip);
|
||||||
state.domainToIPMap.delete(domain);
|
state.domainToIPMap.delete(domain);
|
||||||
logDebug('DomainCleanup', `Removed IP ${ip} for ${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) {
|
} catch (err) {
|
||||||
cleanupErrors.push(`Interface removal: ${err.message}`);
|
cleanupErrors.push(`Interface removal: ${err.message}`);
|
||||||
logError('DomainCleanup', `Error removing interface: ${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);
|
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
|
// Helper function to calculate available IPs for a subnet
|
||||||
function getAvailableIPsForSubnet(subnet) {
|
function getAvailableIPsForSubnet(subnet) {
|
||||||
const maxIPs = Math.pow(2, 32 - subnet.cidr) - 2; // Subtract network and broadcast
|
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
|
// Always set plugin domains to 127.0.0.1
|
||||||
state.domainToIPMap.set(domain, pluginIP);
|
state.domainToIPMap.set(domain, pluginIP);
|
||||||
logDebug('VirtualInterface', `Domain ${domain} is a plugin/internal domain, using 127.0.0.1 (no virtual interface created)`);
|
logDebug('VirtualInterface', `Domain ${domain} is a plugin/internal domain, using 127.0.0.1 (no virtual interface created)`);
|
||||||
|
notifyInterfacesChanged();
|
||||||
return pluginIP;
|
return pluginIP;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -426,6 +436,7 @@ async function createInterfaceForDomain(domain) {
|
|||||||
const placeholderIP = `${subnetBase}${ipIndex}`;
|
const placeholderIP = `${subnetBase}${ipIndex}`;
|
||||||
state.domainToIPMap.set(domain, placeholderIP);
|
state.domainToIPMap.set(domain, placeholderIP);
|
||||||
logDebug('VirtualInterface', `Assigned placeholder IP: ${placeholderIP} for domain: ${domain} (interface not created)`);
|
logDebug('VirtualInterface', `Assigned placeholder IP: ${placeholderIP} for domain: ${domain} (interface not created)`);
|
||||||
|
notifyInterfacesChanged();
|
||||||
}
|
}
|
||||||
return state.domainToIPMap.get(domain);
|
return state.domainToIPMap.get(domain);
|
||||||
}
|
}
|
||||||
@@ -442,11 +453,13 @@ async function createInterfaceForDomain(domain) {
|
|||||||
} else {
|
} else {
|
||||||
logWarn('VirtualInterface', `IP ${existingIP} for domain ${domain} no longer configured, reassigning`);
|
logWarn('VirtualInterface', `IP ${existingIP} for domain ${domain} no longer configured, reassigning`);
|
||||||
state.domainToIPMap.delete(domain);
|
state.domainToIPMap.delete(domain);
|
||||||
|
notifyInterfacesChanged();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logWarn('VirtualInterface', `Removing invalid existing IP ${existingIP} for domain ${domain}`);
|
logWarn('VirtualInterface', `Removing invalid existing IP ${existingIP} for domain ${domain}`);
|
||||||
await removeVirtualInterface(existingIP);
|
await removeVirtualInterface(existingIP);
|
||||||
state.domainToIPMap.delete(domain);
|
state.domainToIPMap.delete(domain);
|
||||||
|
notifyInterfacesChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +488,7 @@ async function createInterfaceForDomain(domain) {
|
|||||||
state.domainToIPMap.set(domain, assignedIP);
|
state.domainToIPMap.set(domain, assignedIP);
|
||||||
logInfo('VirtualInterface', `Assigned virtual interface IP: ${assignedIP} for domain: ${domain}`);
|
logInfo('VirtualInterface', `Assigned virtual interface IP: ${assignedIP} for domain: ${domain}`);
|
||||||
state.currentIP++;
|
state.currentIP++;
|
||||||
|
notifyInterfacesChanged();
|
||||||
return assignedIP;
|
return assignedIP;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
|
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.subnetIPCounters.set(subnetIndex, currentIPIndex + 1);
|
||||||
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
|
state.currentSubnetIndex = (state.currentSubnetIndex + 1) % subnets.length;
|
||||||
|
|
||||||
|
notifyInterfacesChanged();
|
||||||
return assignedIP;
|
return assignedIP;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
|
logError('VirtualInterface', `Failed to assign IP ${ip} for ${domain}: ${error.message}`);
|
||||||
@@ -563,5 +578,6 @@ module.exports = {
|
|||||||
createInterfaceForDomain,
|
createInterfaceForDomain,
|
||||||
getAvailableIPsForSubnet,
|
getAvailableIPsForSubnet,
|
||||||
ipBelongsToSubnet,
|
ipBelongsToSubnet,
|
||||||
generateIPFromSubnet
|
generateIPFromSubnet,
|
||||||
|
getConfiguredIPs
|
||||||
};
|
};
|
||||||
+9
-25
@@ -30,7 +30,7 @@ const { addDomain } = require('../core/domains');
|
|||||||
// Lazy load createInterfaceForDomain to avoid circular dependency
|
// Lazy load createInterfaceForDomain to avoid circular dependency
|
||||||
// const { createInterfaceForDomain } = require('../networking/virtual_interfaces');
|
// const { createInterfaceForDomain } = require('../networking/virtual_interfaces');
|
||||||
const { startHolesailServer, saveHolesailServers } = require('../admin/admin-backend/holesail-servers');
|
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 { saveBlockedPeers, loadBlockedPeers } = require('../admin/admin-backend/cache');
|
||||||
const ca = require('../security/certificate_authority');
|
const ca = require('../security/certificate_authority');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
@@ -1025,19 +1025,8 @@ const sdk = {
|
|||||||
*/
|
*/
|
||||||
listClients() {
|
listClients() {
|
||||||
return Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
return Array.from(state.holesailClientOpts.entries()).map(([id, opts]) => {
|
||||||
const child = state.holesailClientChildren.get(id);
|
|
||||||
const info = state.holesailClientInfos.get(id) || {};
|
const info = state.holesailClientInfos.get(id) || {};
|
||||||
const key = `${opts.domain}:${opts.port}`;
|
const status = getHolesailClientStatus(id, opts, info);
|
||||||
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';
|
|
||||||
}
|
|
||||||
return { id, opts, info: { ...info, state: status } };
|
return { id, opts, info: { ...info, state: status } };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1050,19 +1039,8 @@ const sdk = {
|
|||||||
getClient(id) {
|
getClient(id) {
|
||||||
const opts = state.holesailClientOpts.get(id);
|
const opts = state.holesailClientOpts.get(id);
|
||||||
if (!opts) return null;
|
if (!opts) return null;
|
||||||
const child = state.holesailClientChildren.get(id);
|
|
||||||
const info = state.holesailClientInfos.get(id) || {};
|
const info = state.holesailClientInfos.get(id) || {};
|
||||||
const key = `${opts.domain}:${opts.port}`;
|
const status = getHolesailClientStatus(id, opts, info);
|
||||||
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';
|
|
||||||
}
|
|
||||||
return { id, opts, info: { ...info, state: status } };
|
return { id, opts, info: { ...info, state: status } };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1458,6 +1436,12 @@ const sdk = {
|
|||||||
// This just removes from the map
|
// This just removes from the map
|
||||||
if (state.domainToIPMap) {
|
if (state.domainToIPMap) {
|
||||||
state.domainToIPMap.delete(domain);
|
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