commit
This commit is contained in:
@@ -19,7 +19,13 @@
|
||||
<dt class="col-sm-5">Hostname</dt><dd class="col-sm-7">{{ details.system.hostname }}</dd>
|
||||
<dt class="col-sm-5">Customer</dt><dd class="col-sm-7">{{ details.browser_info.customer_name }}</dd>
|
||||
<dt class="col-sm-5">Common Name</dt><dd class="col-sm-7">{{ details.browser_info.common_name }}</dd>
|
||||
<dt class="col-sm-5">Virtual IP</dt><dd class="col-sm-7">{{ request.view_args.host_ip }}</dd>
|
||||
<dt class="col-sm-5">Virtual IP</dt>
|
||||
<dd class="col-sm-7 d-flex flex-wrap gap-2 align-items-center">
|
||||
<a href="https://{{ request.view_args.host_ip }}" target="_blank" rel="noopener noreferrer" class="link-light">{{ request.view_args.host_ip }}</a>
|
||||
<a href="https://{{ request.view_args.host_ip }}" target="_blank" rel="noopener noreferrer" class="btn btn-outline-info btn-sm">
|
||||
<i class="bi bi-box-arrow-up-right"></i> Open GUI
|
||||
</a>
|
||||
</dd>
|
||||
<dt class="col-sm-5">Public IP</dt><dd class="col-sm-7">{{ details.browser_info.public_ip }}</dd>
|
||||
<dt class="col-sm-5">Connected Since</dt><dd class="col-sm-7">{{ details.browser_info.connected_since }}</dd>
|
||||
</dl>
|
||||
@@ -135,4 +141,4 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,17 +29,69 @@
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer>
|
||||
function renderSupisTable(items = []) {
|
||||
resultsOutput.innerHTML = '';
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
resultsOutput.innerHTML = '<div class="alert alert-info mb-0">No network clients returned for this host.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const preferred = ['supi', 'imsi', 'msisdn', 'profile', 'slice', 'dnn', 'status'];
|
||||
const columns = preferred.filter(key => items.some(item => item[key] !== undefined));
|
||||
if (columns.length === 0) {
|
||||
columns.push(...Object.keys(items[0]).slice(0, 6));
|
||||
}
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'mb-2 text-white-50';
|
||||
summary.textContent = `Showing ${items.length} record${items.length === 1 ? '' : 's'}.`;
|
||||
resultsOutput.appendChild(summary);
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'table-responsive';
|
||||
const table = document.createElement('table');
|
||||
table.className = 'table table-dark table-hover table-sm table-compact align-middle';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headRow = document.createElement('tr');
|
||||
columns.forEach(col => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = col.replace(/_/g, ' ').toUpperCase();
|
||||
headRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
items.forEach(item => {
|
||||
const row = document.createElement('tr');
|
||||
columns.forEach(col => {
|
||||
const cell = document.createElement('td');
|
||||
let value = item[col];
|
||||
if (Array.isArray(value) || (value && typeof value === 'object')) {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
cell.textContent = value ?? '';
|
||||
row.appendChild(cell);
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrapper.appendChild(table);
|
||||
resultsOutput.appendChild(wrapper);
|
||||
}
|
||||
|
||||
document.getElementById('listSupiBtn').addEventListener('click', async () => {
|
||||
const rawHost = document.getElementById('host').value;
|
||||
const rawHost = document.getElementById('host').value;
|
||||
if (!rawHost) {
|
||||
alert('Please enter a 5GC Host IP address.');
|
||||
return;
|
||||
}
|
||||
const host = formatHostIp(rawHost);
|
||||
const supiData = await apiCall('/api/supis/list', { host });
|
||||
if (supiData) {
|
||||
resultsOutput.textContent = JSON.stringify(supiData, null, 2);
|
||||
const response = await apiCall('/api/supis/list', { host });
|
||||
if (response && response.supis) {
|
||||
renderSupisTable(response.supis);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4" id="dashboard-select-wrapper">
|
||||
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
|
||||
<select class="form-select" id="dashboard-select">
|
||||
<option selected>Triton</option>
|
||||
<option>Star</option>
|
||||
<option>Bluebonnet</option>
|
||||
<option>Lonestar</option>
|
||||
<option>Production</option>
|
||||
<option>Test (future)</option>
|
||||
<select class="form-select" id="dashboard-select" {% if not dashboard_names %}disabled{% endif %}>
|
||||
{% if dashboard_names %}
|
||||
{% for name in dashboard_names %}
|
||||
<option value="{{ name }}" {% if loop.first %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="">No dashboards configured</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,4 +187,4 @@
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -9,13 +9,14 @@
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4" id="dashboard-select-wrapper">
|
||||
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
|
||||
<select class="form-select" id="dashboard-select">
|
||||
<option selected>Triton</option>
|
||||
<option>Star</option>
|
||||
<option>Bluebonnet</option>
|
||||
<option>Lonestar</option>
|
||||
<option>Production</option>
|
||||
<option>Test (future)</option>
|
||||
<select class="form-select" id="dashboard-select" {% if not dashboard_names %}disabled{% endif %}>
|
||||
{% if dashboard_names %}
|
||||
{% for name in dashboard_names %}
|
||||
<option value="{{ name }}" {% if loop.first %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="">No dashboards configured</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,4 +124,4 @@
|
||||
parentItem.after(nestedGroup);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4" id="dashboard-select-wrapper">
|
||||
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
|
||||
<select class="form-select" id="dashboard-select">
|
||||
<option selected>Triton</option>
|
||||
<option>Star</option>
|
||||
<option>Production</option>
|
||||
<option>Test (future)</option>
|
||||
<select class="form-select" id="dashboard-select" {% if not dashboard_names %}disabled{% endif %}>
|
||||
{% if dashboard_names %}
|
||||
{% for name in dashboard_names %}
|
||||
<option value="{{ name }}" {% if loop.first %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="">No dashboards configured</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,4 +153,4 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-4" id="dashboard-select-wrapper">
|
||||
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
|
||||
<select class="form-select" id="dashboard-select">
|
||||
<option selected>Triton</option>
|
||||
<option>Star</option>
|
||||
<option>Production</option>
|
||||
<option>Test (future)</option>
|
||||
<select class="form-select" id="dashboard-select" {% if not dashboard_names %}disabled{% endif %}>
|
||||
{% if dashboard_names %}
|
||||
{% for name in dashboard_names %}
|
||||
<option value="{{ name }}" {% if loop.first %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="">No dashboards configured</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,4 +168,4 @@
|
||||
btn.innerHTML = 'Restart VPN';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user