This commit is contained in:
2026-05-07 12:30:51 -04:00
parent 2ed785e214
commit 59d8db92ca
32 changed files with 1796 additions and 290 deletions
+666 -31
View File
@@ -9,7 +9,10 @@
<hr>
<div id="browser-view">
<p>Retrieve and display live status information for all connected customer networks. You can search, sort, and filter the results.</p>
<button class="btn btn-primary" id="getBrowserDataBtn">Get System Status</button>
<div class="d-flex gap-2">
<button class="btn btn-primary" id="getBrowserDataBtn">Get System Status</button>
<button class="btn btn-outline-info" id="retrieveLogsBtn" disabled>Retrieve Logs</button>
</div>
</div>
<div class="row g-3 my-3 align-items-end">
@@ -29,45 +32,208 @@
</div>
{% endblock %}
<div class="modal fade" id="logWizardModal" tabindex="-1" aria-labelledby="logWizardModalLabel">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="logWizardModalLabel">Log Capture Wizard</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="wizard-step-process">
<h5>Process Selection</h5>
<p class="text-muted">Only services in a <span class="text-success">started</span> state are listed. Logs will open in a new window.</p>
<div id="process-list" class="d-grid gap-3"></div>
</div>
</div>
<div class="modal-footer">
<span class="text-muted me-auto" id="logWizardStatus"></span>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="startLogCaptureBtn" disabled>Start Log Capture</button>
</div>
</div>
</div>
</div>
{% block extra_scripts %}
<script defer>
let fullBrowserData = [];
let currentSort = { column: 'customer_id', direction: 'asc' };
document.addEventListener('DOMContentLoaded', () => {
console.log('[system_browser] script initialized');
const searchInput = document.getElementById('search-input');
const customerFilter = document.getElementById('customer-filter');
const retrieveLogsBtn = document.getElementById('retrieveLogsBtn');
let processListEl = document.getElementById('process-list');
let logWizardStatus = document.getElementById('logWizardStatus');
let startLogCaptureBtn = document.getElementById('startLogCaptureBtn');
let logPlayBtn = null;
let logPauseBtn = null;
let logStopBtn = null;
let logClearBtn = null;
let logWizardModalEl = document.getElementById('logWizardModal');
const spinnerEl = document.getElementById('spinner');
const resultsOutputEl = document.getElementById('results-output');
function injectLogWizardModal() {
const wrapper = document.createElement('div');
wrapper.innerHTML = `
<div class="modal fade" id="logWizardModal" tabindex="-1" aria-labelledby="logWizardModalLabel">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="logWizardModalLabel">Log Capture Wizard</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="wizard-step-process">
<h5>Process Selection</h5>
<p class="text-muted">Only services in a <span class="text-success">started</span> state are listed. Logs will open in a new window.</p>
<div id="process-list" class="d-grid gap-3"></div>
</div>
</div>
<div class="modal-footer">
<span class="text-muted me-auto" id="logWizardStatus"></span>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="startLogCaptureBtn" disabled>Start Log Capture</button>
</div>
</div>
</div>
</div>`;
document.body.appendChild(wrapper.firstElementChild);
processListEl = document.getElementById('process-list');
logWizardStatus = document.getElementById('logWizardStatus');
startLogCaptureBtn = document.getElementById('startLogCaptureBtn');
logWizardModalEl = document.getElementById('logWizardModal');
}
if (!logWizardModalEl || !processListEl || !logWizardStatus || !startLogCaptureBtn) {
injectLogWizardModal();
}
if (!logWizardModalEl || !processListEl || !logWizardStatus || !startLogCaptureBtn) {
console.warn('[system_browser] log wizard elements unavailable; disabling log capture workflow');
return;
}
const logWizardModal = new bootstrap.Modal(logWizardModalEl);
let fullBrowserData = [];
let currentSort = { column: 'customer_id', direction: 'asc' };
let selectedHosts = new Map();
let processLookup = new Map();
let processSelections = new Map();
let logController = null;
let lastLogPayload = null;
let logBuffer = [];
let isStreaming = false;
let logWindow = null;
let logFilterInput = null;
let logOutputEl = null;
let logWindowStatusEl = null;
let logWindowMetaEl = null;
let logFilterTerm = '';
async function postJson(endpoint, body = {}, options = {}) {
const { showSpinner = false, clearResults = false } = options;
if (showSpinner && spinnerEl) {
spinnerEl.classList.remove('d-none');
}
if (clearResults && resultsOutputEl) {
resultsOutputEl.innerHTML = '';
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = await response.json();
if (!response.ok) {
const message = result?.error || result?.message || 'Unknown server error';
throw new Error(message);
}
return result;
} catch (error) {
alert(`Error: ${error.message}`);
return null;
} finally {
if (showSpinner && spinnerEl) {
spinnerEl.classList.add('d-none');
}
}
}
function renderTable(data) {
let tableHtml = `<table class="table table-dark table-hover table-sm table-compact">
<thead>
<tr>
<th style="width:2.5rem;"><input class="form-check-input" type="checkbox" id="selectAllHosts"></th>
<th class="tree-item" data-sort="customer_id">Customer ID <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="customer_name">Customer Name <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="common_name">Common Name <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="virtual_ip">Virtual IP <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="public_ip">Public IP <i class="bi bi-arrow-down-up"></i></th>
</tr>
</thead>
<tbody>`;
data.forEach(client => {
// ADDED: The necessary attributes to make the row clickable
tableHtml += `
<tr class="clickable-row"
data-host-ip="${client.virtual_ip}"
data-public-ip="${client.public_ip}"
data-connected-since="${client.connected_since}"
<tr class="clickable-row"
data-host-ip="${client.virtual_ip}"
data-public-ip="${client.public_ip}"
data-connected-since="${client.connected_since}"
data-customer-name="${client.customer_name}"
data-common-name="${client.common_name}"
style="cursor: pointer;">
<td>
<input type="checkbox" class="form-check-input host-select" value="${client.virtual_ip}" data-customer="${client.customer_name}" data-common="${client.common_name}" data-public="${client.public_ip}" />
</td>
<td>${client.customer_id}</td>
<td>${client.customer_name}</td>
<td>${client.common_name}</td>
<td><a href="http://${client.virtual_ip}" target="_blank">${client.virtual_ip}</a></td>
<td><a href="https://${client.virtual_ip}" target="_blank" rel="noopener noreferrer">${client.virtual_ip}</a></td>
<td>${client.public_ip || 'N/A'}</td>
</tr>`;
});
tableHtml += '</tbody></table>';
resultsOutput.innerHTML = tableHtml;
if (resultsOutputEl) {
resultsOutputEl.innerHTML = tableHtml;
}
wireHostSelection(data);
}
function wireHostSelection(tableData) {
selectedHosts.clear();
updateRetrieveLogsButton();
const selectAll = document.getElementById('selectAllHosts');
const boxes = resultsOutputEl ? resultsOutputEl.querySelectorAll('.host-select') : [];
boxes.forEach(cb => {
cb.addEventListener('click', (event) => event.stopPropagation());
cb.addEventListener('change', () => {
const hostIp = cb.value;
const record = tableData.find(item => item.virtual_ip === hostIp);
if (cb.checked && record) {
selectedHosts.set(hostIp, record);
} else {
selectedHosts.delete(hostIp);
}
if (selectAll) {
selectAll.checked = boxes.length > 0 && [...boxes].every(box => box.checked);
}
updateRetrieveLogsButton();
});
});
if (selectAll) {
selectAll.addEventListener('change', () => {
boxes.forEach(box => {
box.checked = selectAll.checked;
box.dispatchEvent(new Event('change'));
});
});
}
}
function updateRetrieveLogsButton() {
retrieveLogsBtn.disabled = selectedHosts.size === 0;
}
function populateCustomerFilter(data) {
@@ -85,19 +251,16 @@
let filteredData = [...fullBrowserData];
const searchTerm = searchInput.value.toLowerCase();
const selectedCustomer = customerFilter.value;
if (selectedCustomer) {
filteredData = filteredData.filter(item => item.customer_name === selectedCustomer);
}
if (searchTerm) {
filteredData = filteredData.filter(item =>
Object.values(item).some(value =>
filteredData = filteredData.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
filteredData.sort((a, b) => {
const valA = a[currentSort.column] || '';
const valB = b[currentSort.column] || '';
@@ -105,12 +268,18 @@
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
renderTable(filteredData);
}
document.getElementById('getBrowserDataBtn').addEventListener('click', async () => {
const browserData = await apiCall('/api/system-browser/data', {});
const getBrowserDataBtn = document.getElementById('getBrowserDataBtn');
console.log('[system_browser] getBrowserDataBtn found?', !!getBrowserDataBtn);
if (!getBrowserDataBtn) {
return;
}
getBrowserDataBtn.addEventListener('click', async () => {
console.log('[system_browser] Get System Status clicked');
const browserData = await postJson('/api/system-browser/data', {}, { showSpinner: true, clearResults: true });
if (browserData) {
fullBrowserData = browserData;
populateCustomerFilter(fullBrowserData);
@@ -118,11 +287,25 @@
}
});
retrieveLogsBtn.addEventListener('click', async () => {
if (selectedHosts.size === 0) {
alert('Select at least one host.');
return;
}
const hosts = [...selectedHosts.keys()];
const response = await postJson('/api/logs/processes', { hosts }, { showSpinner: true, clearResults: false });
if (response && response.hosts) {
populateProcessList(response.hosts);
startLogCaptureBtn.disabled = !hasAnyProcessSelection();
logWizardStatus.textContent = 'Select the processes you would like to capture logs from. Streams open in a new window after you click Start.';
logWizardModal.show();
}
});
searchInput.addEventListener('input', applyFiltersAndSort);
customerFilter.addEventListener('change', applyFiltersAndSort);
// RESTORED: The full click handler for the results area
resultsOutput.addEventListener('click', (event) => {
resultsOutputEl?.addEventListener('click', (event) => {
const header = event.target.closest('th[data-sort]');
if (header) {
const sortColumn = header.dataset.sort;
@@ -135,19 +318,471 @@
applyFiltersAndSort();
return;
}
if (event.target.closest('.host-select')) {
return;
}
const row = event.target.closest('.clickable-row');
if (row) {
const hostIp = row.dataset.hostIp;
if (hostIp && hostIp !== 'N/A') {
const customerName = encodeURIComponent(row.dataset.customerName);
const commonName = encodeURIComponent(row.dataset.commonName);
const publicIp = encodeURIComponent(row.dataset.publicIp);
const connectedSince = encodeURIComponent(row.dataset.connectedSince);
window.location.href = `/host/${hostIp}?customer_name=${customerName}&common_name=${commonName}&public_ip=${publicIp}&connected_since=${connectedSince}`;
const params = new URLSearchParams({
customer_name: row.dataset.customerName,
common_name: row.dataset.commonName,
public_ip: row.dataset.publicIp,
connected_since: row.dataset.connectedSince
});
window.location.href = `/host/${hostIp}?${params.toString()}`;
}
}
});
function populateProcessList(hostSummaries) {
processLookup.clear();
processSelections.clear();
processListEl.innerHTML = '';
hostSummaries.forEach(summary => {
const card = document.createElement('div');
card.className = 'card bg-secondary bg-opacity-25';
const header = document.createElement('div');
header.className = 'card-header d-flex justify-content-between align-items-center';
header.innerHTML = `<strong>${summary.hostname || summary.host}</strong><span class="badge bg-dark">${summary.host}</span>`;
const body = document.createElement('div');
body.className = 'card-body';
if (summary.error) {
body.innerHTML = `<div class="alert alert-danger mb-0">${summary.error}</div>`;
} else if (!summary.services || summary.services.length === 0) {
body.innerHTML = '<div class="alert alert-warning mb-0">No running services detected.</div>';
} else {
processLookup.set(summary.host, summary);
const defaultSet = new Set(summary.services.map(svc => svc.name));
processSelections.set(summary.host, defaultSet);
const toggleBtn = document.createElement('button');
toggleBtn.className = 'btn btn-sm btn-outline-light mb-2';
toggleBtn.textContent = 'Toggle All';
toggleBtn.addEventListener('click', () => {
const set = processSelections.get(summary.host) || new Set();
if (set.size === summary.services.length) {
set.clear();
} else {
summary.services.forEach(svc => set.add(svc.name));
}
processSelections.set(summary.host, set);
body.querySelectorAll('.process-checkbox').forEach(cb => {
cb.checked = set.has(cb.value);
});
updateStartButtonState();
});
body.appendChild(toggleBtn);
summary.services.forEach(service => {
const id = `${summary.host}-${service.name}`.replace(/[^a-zA-Z0-9_-]/g, '_');
const wrapper = document.createElement('div');
wrapper.className = 'form-check form-switch text-white-50';
wrapper.innerHTML = `
<input class="form-check-input process-checkbox" type="checkbox" id="${id}" value="${service.name}" checked>
<label class="form-check-label" for="${id}">${service.name}</label>
`;
const checkbox = wrapper.querySelector('input');
checkbox.addEventListener('change', () => {
const set = processSelections.get(summary.host) || new Set();
if (checkbox.checked) {
set.add(checkbox.value);
} else {
set.delete(checkbox.value);
}
processSelections.set(summary.host, set);
updateStartButtonState();
});
body.appendChild(wrapper);
});
}
card.appendChild(header);
card.appendChild(body);
processListEl.appendChild(card);
});
updateStartButtonState();
}
function hasAnyProcessSelection() {
for (const set of processSelections.values()) {
if (set.size > 0) {
return true;
}
}
return false;
}
function updateStartButtonState() {
startLogCaptureBtn.disabled = !hasAnyProcessSelection();
}
function resetLogWindowRefs() {
logWindow = null;
logOutputEl = null;
logFilterInput = null;
logWindowStatusEl = null;
logWindowMetaEl = null;
logPlayBtn = null;
logPauseBtn = null;
logStopBtn = null;
logClearBtn = null;
logFilterTerm = '';
}
function buildLogWindowMarkup(targets) {
const hostItems = targets.map(t => {
const procList = (t.processes || []).join(', ');
return `
<li class="list-group-item bg-dark text-white border-secondary">
<div class="d-flex justify-content-between align-items-center">
<strong>${t.hostname || t.host}</strong>
<span class="text-secondary small">${t.host}</span>
</div>
<div class="small text-info">Processes: ${procList}</div>
</li>`;
}).join('');
return `
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Live Log Capture</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { background-color: #080a0e; color: #f8f9fa; font-family: "Inter", "Segoe UI", sans-serif; }
pre#log-output { white-space: pre-wrap; word-break: break-word; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 0.85rem; line-height: 1.35; min-height: 400px; max-height: calc(100vh - 260px); overflow:auto; background-color: #050608; color: #8ee5a1; border: 1px solid rgba(255,255,255,0.08); }
</style>
</head>
<body>
<div class="container-fluid py-3" style="max-width: 1100px;">
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="mb-0">Live Log Capture</h3>
<span id="logWindowStatus" class="text-warning small"></span>
</div>
<p id="logWindowMeta" class="text-muted small"></p>
<div class="card bg-secondary bg-opacity-25 mb-3">
<div class="card-body p-2">
<h6 class="text-uppercase small text-muted mb-2">Targets</h6>
<ul class="list-group list-group-flush">${hostItems}</ul>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-success btn-sm" id="logPlayBtn" disabled><i class="bi bi-play-fill"></i> Play</button>
<button class="btn btn-warning btn-sm" id="logPauseBtn" disabled><i class="bi bi-pause-fill"></i> Pause</button>
<button class="btn btn-danger btn-sm" id="logStopBtn" disabled><i class="bi bi-stop-fill"></i> Stop</button>
<button class="btn btn-outline-light btn-sm" id="logClearBtn"><i class="bi bi-eraser"></i> Clear</button>
<div class="btn-group">
<button class="btn btn-outline-info btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-download"></i> Export
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="#" data-export-format="text">Text</a></li>
<li><a class="dropdown-item" href="#" data-export-format="csv">CSV</a></li>
<li><a class="dropdown-item" href="#" data-export-format="json">JSON</a></li>
</ul>
</div>
<div class="flex-grow-1 min-w-25">
<input type="text" class="form-control form-control-sm" id="logFilterInput" placeholder="Filter logs (live)">
</div>
</div>
<pre id="log-output" class="rounded p-3"></pre>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"><\/script>
</body>
</html>`;
}
function openLogWindow(targets) {
try {
if (logWindow && !logWindow.closed) {
logWindow.close();
}
logWindow = window.open('', 'logCaptureWindow', 'width=1100,height=720,resizable=yes,scrollbars=yes');
} catch (error) {
console.error('Failed to open log window', error);
alert('Unable to open log window. Please allow pop-ups for this site.');
logWindow = null;
return false;
}
if (!logWindow) {
alert('Unable to open log window. Please allow pop-ups for this site.');
return false;
}
logWindow.document.write(buildLogWindowMarkup(targets));
logWindow.document.close();
logWindow.addEventListener('beforeunload', () => {
stopLogStreaming();
resetLogWindowRefs();
});
logOutputEl = logWindow.document.getElementById('log-output');
logFilterInput = logWindow.document.getElementById('logFilterInput');
logWindowStatusEl = logWindow.document.getElementById('logWindowStatus');
logWindowMetaEl = logWindow.document.getElementById('logWindowMeta');
logPlayBtn = logWindow.document.getElementById('logPlayBtn');
logPauseBtn = logWindow.document.getElementById('logPauseBtn');
logStopBtn = logWindow.document.getElementById('logStopBtn');
logClearBtn = logWindow.document.getElementById('logClearBtn');
logWindow.document.querySelectorAll('[data-export-format]').forEach(link => {
link.addEventListener('click', (event) => {
event.preventDefault();
exportLogs(link.dataset.exportFormat);
});
});
if (logFilterInput) {
logFilterInput.addEventListener('input', (event) => {
logFilterTerm = (event.target.value || '').toLowerCase();
renderLogBuffer(false);
});
}
logPlayBtn?.addEventListener('click', handleLogPlay);
logPauseBtn?.addEventListener('click', handleLogPause);
logStopBtn?.addEventListener('click', handleLogStop);
logClearBtn?.addEventListener('click', handleLogClear);
const hostSummary = `${targets.length} host(s) Processes: ${[...new Set(targets.flatMap(t => t.processes))].join(', ')}`;
if (logWindowMetaEl) {
logWindowMetaEl.textContent = hostSummary;
}
logFilterTerm = '';
renderLogBuffer(false);
updateLogControls();
setLogWindowStatus('Log window ready.');
logWindow.focus();
return true;
}
function setLogWindowStatus(message) {
if (logWindowStatusEl && logWindow && !logWindow.closed) {
logWindowStatusEl.textContent = message;
} else if (logWizardStatus) {
logWizardStatus.textContent = message;
}
}
function renderLogBuffer(scrollToEnd = true) {
if (!logOutputEl || !logWindow || logWindow.closed) {
return;
}
const normalizedFilter = logFilterTerm;
const filtered = normalizedFilter
? logBuffer.filter(entry =>
entry.line.toLowerCase().includes(normalizedFilter) ||
(entry.hostname || entry.host || '').toLowerCase().includes(normalizedFilter))
: logBuffer;
logOutputEl.textContent = filtered.map(entry => `${entry.timestamp} [${entry.hostname || entry.host}] ${entry.line}`).join('\n');
if (scrollToEnd) {
logOutputEl.scrollTop = logOutputEl.scrollHeight;
}
}
function handleLogPlay() {
if (lastLogPayload) {
setLogWindowStatus('Resuming log capture...');
startLogStreaming(lastLogPayload);
}
}
function handleLogPause() {
stopLogStreaming(false);
setLogWindowStatus('Log capture paused.');
}
function handleLogStop() {
stopLogStreaming();
setLogWindowStatus('Log capture stopped.');
}
function handleLogClear() {
logBuffer = [];
renderLogBuffer(false);
}
startLogCaptureBtn.addEventListener('click', () => {
const targets = buildLogTargets();
if (!targets.length) {
alert('Select at least one process.');
return;
}
if (!openLogWindow(targets)) {
setLogWindowStatus('Pop-up blocked. Please allow pop-ups and try again.');
return;
}
logBuffer = [];
renderLogBuffer(false);
setLogWindowStatus('Starting log capture...');
logWizardModal.hide();
startLogStreaming({ targets });
});
function buildLogTargets() {
const targets = [];
processSelections.forEach((set, host) => {
if (set.size > 0) {
const meta = processLookup.get(host) || {};
targets.push({ host, hostname: meta.hostname, processes: [...set] });
}
});
return targets;
}
function startLogStreaming(payload) {
stopLogStreaming(false);
lastLogPayload = payload;
isStreaming = true;
updateLogControls();
setLogWindowStatus('Streaming logs...');
logController = new AbortController();
fetch('/api/logs/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: logController.signal,
}).then(response => {
if (!response.ok) {
throw new Error('Failed to start log stream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const readChunk = () => {
reader.read().then(({ value, done }) => {
if (done) {
isStreaming = false;
updateLogControls();
setLogWindowStatus('Log stream ended.');
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
lines.forEach(line => {
if (!line.trim()) return;
try {
handleLogEvent(JSON.parse(line));
} catch (err) {
console.error('Failed to parse log event', err, line);
}
});
readChunk();
}).catch(err => {
if (err.name === 'AbortError') {
setLogWindowStatus('Log capture paused.');
} else {
console.error(err);
setLogWindowStatus(`Log stream error: ${err.message}`);
}
isStreaming = false;
updateLogControls();
});
};
readChunk();
}).catch(err => {
if (err.name !== 'AbortError') {
alert(err.message);
}
isStreaming = false;
updateLogControls();
setLogWindowStatus(`Log stream error: ${err.message}`);
});
}
function handleLogEvent(event) {
if (event.type === 'heartbeat') {
return;
}
if (event.type === 'log') {
logBuffer.push({
timestamp: event.timestamp,
host: event.host,
hostname: event.hostname,
line: event.line,
});
} else if (event.type === 'error') {
logBuffer.push({
timestamp: event.timestamp || new Date().toISOString(),
host: event.host,
hostname: event.hostname,
line: `ERROR: ${event.message}`,
});
} else if (event.type === 'complete') {
logBuffer.push({
timestamp: event.timestamp || new Date().toISOString(),
host: event.host,
hostname: event.hostname,
line: 'stream completed.',
});
}
appendLogLine();
}
function appendLogLine() {
renderLogBuffer(true);
}
function stopLogStreaming(clearPayload = true) {
if (logController) {
logController.abort();
logController = null;
}
if (clearPayload) {
lastLogPayload = null;
}
isStreaming = false;
updateLogControls();
if (clearPayload) {
setLogWindowStatus('Log capture stopped.');
}
}
function updateLogControls() {
if (logPlayBtn) {
logPlayBtn.disabled = isStreaming || !lastLogPayload;
}
if (logPauseBtn) {
logPauseBtn.disabled = !isStreaming;
}
if (logStopBtn) {
logStopBtn.disabled = !lastLogPayload;
}
}
function exportLogs(format) {
if (!logBuffer.length) {
alert('No logs to export.');
return;
}
let mime = 'text/plain';
let content = '';
if (format === 'json') {
mime = 'application/json';
content = JSON.stringify(logBuffer, null, 2);
} else if (format === 'csv') {
mime = 'text/csv';
const header = 'timestamp,host,hostname,line';
const rows = logBuffer.map(entry => [entry.timestamp, entry.host, entry.hostname || '', entry.line.replace(/"/g, '""')].map(val => `"${val || ''}"`).join(','));
content = [header, ...rows].join('\n');
} else {
content = logBuffer.map(entry => `${entry.timestamp} [${entry.hostname || entry.host}] ${entry.line}`).join('\n');
}
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `logs.${format === 'text' ? 'txt' : format}`;
a.click();
URL.revokeObjectURL(url);
}
logWizardModalEl.addEventListener('hidden.bs.modal', () => {
if (!isStreaming) {
logBuffer = [];
}
if (logWizardStatus) {
logWizardStatus.textContent = '';
}
});
});
</script>
{% endblock %}
{% endblock %}