Upgrade logs

This commit is contained in:
Raven Scott
2026-05-28 12:12:24 -04:00
parent 6ee20a3f68
commit ca324ec4a6
15 changed files with 610 additions and 105 deletions
@@ -0,0 +1,61 @@
/**
* Per-channel log tail subscriptions over admin WebSocket.
*/
const { getLogTail, listLogChannels } = require('../../infrastructure/log-files');
/** @type {Map<import('ws'), string>} */
const logSubscribers = new Map();
function handleLogWsMessage(ws, data) {
if (data.type === 'subscribe-log') {
const channel = data.channel || 'core';
if (!listLogChannels().some((c) => c.id === channel)) {
return false;
}
logSubscribers.set(ws, channel);
if (ws.readyState === 1) {
const lines = getLogTail(channel, data.lines || 500);
ws.send(JSON.stringify({
type: 'log-snapshot',
channel,
lines
}));
}
return true;
}
if (data.type === 'unsubscribe-log') {
logSubscribers.delete(ws);
return true;
}
return false;
}
function onLogWsClose(ws) {
logSubscribers.delete(ws);
}
function notifyLogSubscribers(channelId, level, line) {
const payload = JSON.stringify({
type: 'file-log',
channel: channelId,
level,
message: line
});
for (const [ws, subscribed] of logSubscribers.entries()) {
if (subscribed !== channelId) continue;
if (ws.readyState === 1) {
ws.send(payload);
}
}
}
function closeLogWebSocketState() {
logSubscribers.clear();
}
module.exports = {
handleLogWsMessage,
onLogWsClose,
notifyLogSubscribers,
closeLogWebSocketState
};
+3 -1
View File
@@ -15,6 +15,7 @@ const { handleBackupsRoutes } = require('./backups');
const { handleDiagnosticsRoutes } = require('./diagnostics');
const { handleConsensusRoutes } = require('./consensus');
const { handlePluginsRoutes } = require('./plugins');
const { handleLogsRoutes } = require('./logs');
async function handleAdminRequest(req, res) {
const url = new URL(req.url, `https://${req.headers.host}`);
@@ -52,7 +53,8 @@ async function handleAdminRequest(req, res) {
if (await handleDiagnosticsRoutes(req, res)) return;
if (await handleConsensusRoutes(req, res)) return;
if (await handlePluginsRoutes(req, res)) return;
if (await handleLogsRoutes(req, res)) return;
// No route matched
res.writeHead(404);
res.end('Not Found');
@@ -0,0 +1,33 @@
const { listLogChannels, getLogTail } = require('../../../infrastructure/log-files');
async function handleLogsRoutes(req, res) {
const urlPath = req.urlPath;
const method = req.method;
if (method === 'GET' && urlPath === '/api/logs') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ channels: listLogChannels() }));
return true;
}
const match = urlPath.match(/^\/api\/logs\/([a-z]+)$/);
if (method === 'GET' && match) {
const channelId = match[1];
const channels = listLogChannels();
if (!channels.some((c) => c.id === channelId)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unknown log channel' }));
return true;
}
const url = new URL(req.url, `https://${req.headers.host}`);
const lines = Math.min(parseInt(url.searchParams.get('lines') || '500', 10), 5000);
const tail = getLogTail(channelId, lines);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ channel: channelId, lines: tail }));
return true;
}
return false;
}
module.exports = { handleLogsRoutes };
+10
View File
@@ -11,6 +11,11 @@ const {
notifyStatsSubscribers,
closeStatsWebSocketState
} = require('./stats-websocket');
const {
handleLogWsMessage,
onLogWsClose,
closeLogWebSocketState
} = require('./log-websocket');
const adminWss = new WebSocket.Server({ noServer: true });
const adminClients = new Set();
@@ -119,6 +124,8 @@ adminWss.on('connection', (ws) => {
});
} else if (handleStatsWsMessage(ws, data)) {
// subscribe-stats / unsubscribe-stats / request-stats-snapshot
} else if (handleLogWsMessage(ws, data)) {
// subscribe-log / unsubscribe-log
}
} catch (err) {
logError('Admin', `Error handling WebSocket message: ${err.message}`);
@@ -128,6 +135,7 @@ adminWss.on('connection', (ws) => {
// Handle close event
ws.on('close', () => {
onStatsWsClose(ws);
onLogWsClose(ws);
adminClients.delete(ws);
logDebug('Admin', 'WebSocket client disconnected');
});
@@ -136,6 +144,7 @@ adminWss.on('connection', (ws) => {
ws.on('error', (err) => {
logError('Admin', `WebSocket error: ${err.message}`);
onStatsWsClose(ws);
onLogWsClose(ws);
adminClients.delete(ws);
try {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
@@ -186,6 +195,7 @@ function closeAllWebSockets() {
}
adminClients.clear();
closeStatsWebSocketState();
closeLogWebSocketState();
// Stop health broadcasts
stopHealthBroadcasts();
+40
View File
@@ -309,6 +309,46 @@
Logs
<button onclick="openInfoModal('logs')" class="text-sm px-3 py-1 theme-button-info rounded transition-colors">Info</button>
</h2>
<div class="flex flex-wrap items-center gap-3 mb-3">
<label for="log-channel-select" class="text-sm theme-text-secondary">Log file</label>
<select id="log-channel-select" onchange="onLogChannelChange()" class="theme-input rounded-lg px-3 py-2 text-sm min-w-[12rem]">
<option value="core">Core</option>
<option value="proxy">Internal Proxy</option>
<option value="dns">DNS</option>
<option value="plugins">Plugins</option>
<option value="holesail">Holesail</option>
</select>
<div class="flex-1 min-w-[16rem] max-w-2xl flex items-center gap-2">
<div class="relative flex-1 group">
<span class="absolute left-3 top-1/2 -translate-y-1/2 theme-text-tertiary pointer-events-none group-focus-within:text-indigo-400 transition-colors">
<i class="fas fa-magnifying-glass text-sm" aria-hidden="true"></i>
</span>
<input
id="log-filter-input"
type="search"
autocomplete="off"
spellcheck="false"
placeholder="Filter lines… text or /regex/flags"
class="w-full theme-input rounded-lg py-2 pl-9 pr-9 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
aria-label="Filter log lines"
oninput="onLogFilterInput()"
onkeydown="if (event.key === 'Escape') { clearLogFilter(); event.preventDefault(); }"
/>
<button
id="log-filter-clear"
type="button"
class="hidden absolute right-1.5 top-1/2 -translate-y-1/2 p-1.5 rounded-md theme-text-tertiary hover:theme-text-primary hover:bg-white/10 transition-colors"
title="Clear filter (Esc)"
aria-label="Clear filter"
onclick="clearLogFilter()"
>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
<span id="log-filter-stats" class="text-xs theme-text-tertiary tabular-nums whitespace-nowrap min-w-[5rem] text-right"></span>
</div>
<span class="text-xs theme-text-tertiary hidden lg:inline">Files under <code class="text-indigo-300">logs/</code> — live tail</span>
</div>
<div id="terminal" class="bg-black rounded-lg overflow-hidden h-96"></div>
</div>
@@ -256,23 +256,23 @@ const infoContent = {
},
'logs': {
title: 'System Logs',
description: 'View real-time system logs',
description: 'View and tail split log files under logs/',
sections: [
{
title: 'Overview',
content: 'The Logs tab shows real-time logs from your P2NS instance. Logs are displayed in a terminal interface.'
title: 'Log files',
content: 'Choose a log from the dropdown: core.log (main process), proxy.log (Internal Proxy), dns.log (DNS resolver), plugins.log (plugin SDK), holesail.log (Holesail). Noisy proxy and DNS traffic no longer appears in core.'
},
{
title: 'Log Levels',
content: 'Logs are color-coded by level: INFO (normal), WARN (yellow), ERROR (red), and DEBUG (gray).'
title: 'On disk',
content: 'Each channel is appended to logs/<name>.log in the project directory (override with LOG_DIR). Files persist across restarts.'
},
{
title: 'Log Buffer',
content: 'The terminal maintains a buffer of the most recent log messages. Older logs are automatically removed to manage memory.'
title: 'Live tail',
content: 'Selecting a log subscribes via WebSocket for new lines. On connect, the last ~1000 lines are loaded as a snapshot.'
},
{
title: 'Real-time Updates',
content: 'Logs are updated in real-time via WebSocket connections. If the WebSocket is disconnected, the logs will pause until reconnection.'
title: 'Console',
content: 'Only core-channel logs are printed to the terminal console. Other channels are file + admin UI only.'
}
]
},
+204 -13
View File
@@ -1,9 +1,180 @@
// Logs UI functions - terminal rendering
// Logs UI — per-channel file tail via WebSocket + debounced filter
const LOG_CHANNEL_LABELS = {
core: 'Core',
proxy: 'Internal Proxy',
dns: 'DNS',
plugins: 'Plugins',
holesail: 'Holesail'
};
const LOG_FILTER_DEBOUNCE_MS = 200;
function getLogFilterDebounced() {
if (!window._logFilterDebounced) {
const debounce = window.sdk?.utils?.dom?.debounce;
window._logFilterDebounced = debounce
? debounce(() => {
if (window.activeTab === 'logs') {
paintLogTerminal(getActiveLogChannel());
}
}, LOG_FILTER_DEBOUNCE_MS)
: () => {
if (window.activeTab === 'logs') {
paintLogTerminal(getActiveLogChannel());
}
};
}
return window._logFilterDebounced;
}
function getActiveLogChannel() {
return window.activeLogChannel || 'core';
}
function getLogFilterQuery() {
const el = document.getElementById('log-filter-input');
return (el?.value || '').trim();
}
/**
* Match log line against filter (case-insensitive substring, or /pattern/flags regex).
*/
function lineMatchesLogFilter(line, query) {
if (!query) return true;
const text = String(line);
if (query.length >= 2 && query.startsWith('/')) {
const lastSlash = query.lastIndexOf('/');
if (lastSlash > 0) {
const pattern = query.slice(1, lastSlash);
const flags = query.slice(lastSlash + 1) || 'i';
try {
return new RegExp(pattern, flags).test(text);
} catch {
// invalid regex — fall through to substring
}
}
}
return text.toLowerCase().includes(query.toLowerCase());
}
function getFilteredLogLines(channel) {
const buf = getLogBuffer(channel);
const query = getLogFilterQuery();
if (!query) {
return { lines: buf, total: buf.length, shown: buf.length, query: '' };
}
const lines = buf.filter((line) => lineMatchesLogFilter(line, query));
return { lines, total: buf.length, shown: lines.length, query };
}
function updateLogFilterStats(channel) {
const el = document.getElementById('log-filter-stats');
if (!el) return;
const { total, shown, query } = getFilteredLogLines(channel);
if (!query) {
el.textContent = total === 0 ? '—' : `${total} line${total === 1 ? '' : 's'}`;
el.className = 'text-xs theme-text-tertiary tabular-nums whitespace-nowrap min-w-[5rem] text-right';
return;
}
el.textContent = `${shown} / ${total}`;
el.className = shown === 0
? 'text-xs text-amber-400 tabular-nums whitespace-nowrap min-w-[5rem] text-right font-medium'
: 'text-xs text-indigo-300 tabular-nums whitespace-nowrap min-w-[5rem] text-right font-medium';
el.title = `${shown} of ${total} lines match “${query}`;
}
function updateLogFilterClearButton() {
const btn = document.getElementById('log-filter-clear');
if (!btn) return;
btn.classList.toggle('hidden', !getLogFilterQuery());
}
function onLogFilterInput() {
updateLogFilterClearButton();
getLogFilterDebounced()();
}
function clearLogFilter() {
const input = document.getElementById('log-filter-input');
if (input) {
input.value = '';
input.focus();
}
updateLogFilterClearButton();
paintLogTerminal(getActiveLogChannel());
}
function getLogBuffer(channel) {
if (!window.logBuffers) window.logBuffers = new Map();
if (!window.logBuffers.has(channel)) {
window.logBuffers.set(channel, []);
}
return window.logBuffers.get(channel);
}
function pushLogLine(channel, line) {
const buf = getLogBuffer(channel);
buf.push(line);
const max = window.maxLogLines || 1000;
if (buf.length > max) buf.shift();
}
function writelnToTerminal(line, channel) {
if (!window.term) return;
const ch = channel || getActiveLogChannel();
if (!lineMatchesLogFilter(line, getLogFilterQuery())) return;
window.term.writeln(line);
}
function paintLogTerminal(channel) {
if (!window.term) return;
window.term.reset();
const { lines } = getFilteredLogLines(channel);
lines.forEach((line) => window.term.writeln(line));
if (window.fitAddon) window.fitAddon.fit();
updateLogFilterStats(channel);
updateLogFilterClearButton();
}
function subscribeLogChannel(channel) {
window.activeLogChannel = channel;
if (window.ws && window.ws.readyState === WebSocket.OPEN) {
window.ws.send(JSON.stringify({ type: 'subscribe-log', channel, lines: window.maxLogLines || 1000 }));
}
}
function onLogChannelChange() {
const select = document.getElementById('log-channel-select');
const channel = select?.value || 'core';
subscribeLogChannel(channel);
paintLogTerminal(channel);
}
async function loadLogChannels() {
const select = document.getElementById('log-channel-select');
if (!select) return;
try {
const res = await fetch('/api/logs');
if (!res.ok) return;
const data = await res.json();
const channels = data.channels || [];
select.innerHTML = channels
.map((c) => `<option value="${c.id}">${c.label || LOG_CHANNEL_LABELS[c.id] || c.id}</option>`)
.join('');
const preferred = window.activeLogChannel || 'core';
if (channels.some((c) => c.id === preferred)) {
select.value = preferred;
}
} catch (err) {
console.error('Failed to load log channels:', err);
}
}
function renderLogs() {
if (!window.terminalInitialized) {
// Check if Terminal is available (from xterm.js CDN)
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
console.error('Terminal or FitAddon not loaded. Make sure xterm.js scripts are loaded.');
console.error('Terminal or FitAddon not loaded.');
return;
}
window.term = new Terminal();
@@ -18,18 +189,33 @@ function renderLogs() {
return;
}
}
if (window.term) {
window.term.reset();
if (window.logBuffer && window.logBuffer.length > 0) {
window.logBuffer.forEach(line => window.term.writeln(line));
}
if (window.fitAddon) {
window.fitAddon.fit();
}
loadLogChannels().then(() => {
const channel = getActiveLogChannel();
const select = document.getElementById('log-channel-select');
if (select) select.value = channel;
subscribeLogChannel(channel);
paintLogTerminal(channel);
});
}
function applyLogSnapshot(data) {
if (!data?.channel || !Array.isArray(data.lines)) return;
window.logBuffers.set(data.channel, data.lines.slice(-(window.maxLogLines || 1000)));
if (window.activeTab === 'logs' && data.channel === getActiveLogChannel()) {
paintLogTerminal(data.channel);
}
}
function applyFileLog(data) {
if (!data?.channel || !data.message) return;
pushLogLine(data.channel, data.message);
if (window.activeTab === 'logs' && data.channel === getActiveLogChannel()) {
writelnToTerminal(data.message, data.channel);
updateLogFilterStats(data.channel);
}
}
// Handle window resize for terminal
window.addEventListener('resize', () => {
if (window.activeTab === 'logs' && window.fitAddon) {
window.fitAddon.fit();
@@ -41,4 +227,9 @@ window.addEventListener('resize', () => {
});
window.renderLogs = renderLogs;
window.onLogChannelChange = onLogChannelChange;
window.onLogFilterInput = onLogFilterInput;
window.clearLogFilter = clearLogFilter;
window.applyLogSnapshot = applyLogSnapshot;
window.applyFileLog = applyFileLog;
window.subscribeLogChannel = subscribeLogChannel;
@@ -6,6 +6,8 @@ window.wsReconnectAttempts = 0;
window.wsPollingInterval = null;
window.statusUpdateInterval = null;
window.logBuffer = [];
window.logBuffers = new Map();
window.activeLogChannel = 'core';
window.maxLogLines = 1000;
window.terminalInitialized = false;
window.holesailLogBuffers = new Map();
+9 -4
View File
@@ -68,6 +68,9 @@ function connectWebSocket() {
if (window.activeTab === 'stats' && window.subscribeStatsWebSocket) {
window.subscribeStatsWebSocket();
}
if (window.activeTab === 'logs' && window.subscribeLogChannel) {
window.subscribeLogChannel(window.activeLogChannel || 'core');
}
};
window.ws.onclose = () => {
@@ -239,11 +242,13 @@ function connectWebSocket() {
}
});
}
} else if (data.type === 'log-snapshot' && window.applyLogSnapshot) {
window.applyLogSnapshot(data);
} else if (data.type === 'file-log' && window.applyFileLog) {
window.applyFileLog(data);
} else if (data.type === 'log') {
window.logBuffer.push(`[${data.level.toUpperCase()}] ${data.message}`);
if (window.logBuffer.length > window.maxLogLines) window.logBuffer.shift();
if (window.activeTab === 'logs' && window.term) {
window.term.writeln(`[${data.level.toUpperCase()}] ${data.message}`);
if (window.applyFileLog) {
window.applyFileLog({ channel: 'core', level: data.level, message: data.message });
}
} else if (data.type === 'holesail-log') {
let buffer = window.holesailLogBuffers.get(data.id) || [];
+2
View File
@@ -6,6 +6,8 @@ window.wsReconnectAttempts = 0;
window.wsPollingInterval = null;
window.statusUpdateInterval = null;
window.logBuffer = [];
window.logBuffers = new Map();
window.activeLogChannel = 'core';
window.maxLogLines = 1000;
window.terminalInitialized = false;
window.term = null;
+6 -4
View File
@@ -195,11 +195,13 @@ function connectWebSocket() {
}
});
}
} else if (data.type === 'log-snapshot' && window.applyLogSnapshot) {
window.applyLogSnapshot(data);
} else if (data.type === 'file-log' && window.applyFileLog) {
window.applyFileLog(data);
} else if (data.type === 'log') {
window.logBuffer.push(`[${data.level.toUpperCase()}] ${data.message}`);
if (window.logBuffer.length > window.maxLogLines) window.logBuffer.shift();
if (window.activeTab === 'logs' && window.term) {
window.term.writeln(`[${data.level.toUpperCase()}] ${data.message}`);
if (window.applyFileLog) {
window.applyFileLog({ channel: 'core', level: data.level, message: data.message });
}
} else if (data.type === 'holesail-log') {
let buffer = window.holesailLogBuffers.get(data.id) || [];