Files
2026-05-07 12:30:51 -04:00

789 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layout.html" %}
{% set active_page = 'system_browser' %}
{% block title %}System Browser - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">System Browser</h2>
</div>
<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>
<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">
<div class="col-md-6">
<label for="customer-filter" class="form-label">Filter by Customer</label>
<select id="customer-filter" class="form-select"></select>
</div>
<div class="col-md-6">
<label for="search-input" class="form-label">Search Results</label>
<input type="text" id="search-input" class="form-control" placeholder="Search any column...">
</div>
</div>
<div class="mt-4">
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</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>
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 => {
tableHtml += `
<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="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>';
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) {
const customers = [...new Set(data.map(item => item.customer_name))].sort();
customerFilter.innerHTML = '<option value="">All Customers</option>';
customers.forEach(customer => {
const option = document.createElement('option');
option.value = customer;
option.textContent = customer;
customerFilter.appendChild(option);
});
}
function applyFiltersAndSort() {
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 =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
filteredData.sort((a, b) => {
const valA = a[currentSort.column] || '';
const valB = b[currentSort.column] || '';
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
renderTable(filteredData);
}
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);
applyFiltersAndSort();
}
});
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);
resultsOutputEl?.addEventListener('click', (event) => {
const header = event.target.closest('th[data-sort]');
if (header) {
const sortColumn = header.dataset.sort;
if (currentSort.column === sortColumn) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = sortColumn;
currentSort.direction = 'asc';
}
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 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 %}