789 lines
32 KiB
HTML
789 lines
32 KiB
HTML
{% 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 %}
|