368 lines
17 KiB
HTML
368 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Ignition SCADA Progress Monitor</title>
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
body { padding: 20px; padding-bottom: 60px; /* Account for status bar */ }
|
|
.progress-container, .chart-container {
|
|
margin-bottom: 25px;
|
|
text-align: center; /* Center chart labels */
|
|
}
|
|
.chart-label {
|
|
font-weight: bold;
|
|
margin-bottom: 5px;
|
|
display: block;
|
|
}
|
|
.status-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
background-color: #f8f9fa;
|
|
border-top: 1px solid #dee2e6;
|
|
padding: 5px 15px;
|
|
font-size: 0.9em;
|
|
z-index: 1000;
|
|
}
|
|
/* Style for the overall progress bar */
|
|
.overall-progress-bar {
|
|
height: 25px;
|
|
font-size: 1rem;
|
|
}
|
|
.progress-bar-label {
|
|
position: absolute;
|
|
width: 100%;
|
|
text-align: center;
|
|
line-height: 25px; /* Match overall progress bar height */
|
|
color: white; /* Or black, depending on bar color */
|
|
mix-blend-mode: difference; /* Improve visibility */
|
|
font-weight: bold;
|
|
}
|
|
/* Style for panel charts */
|
|
.panel-chart-canvas {
|
|
max-width: 150px; /* Control pie chart size */
|
|
max-height: 150px;
|
|
margin: 0 auto; /* Center the canvas */
|
|
cursor: pointer; /* Indicate clickable */
|
|
}
|
|
#panels-progress {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* Responsive grid */
|
|
gap: 20px;
|
|
}
|
|
.modal-body table { width: 100%; }
|
|
.modal-body th, .modal-body td { padding: 5px 10px; border-bottom: 1px solid #eee; }
|
|
.modal-body th { background-color: #f8f9fa; text-align: left; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1 class="mb-4">SCADA Device Placement Progress</h1>
|
|
|
|
<div id="overall-progress" class="chart-container">
|
|
<span class="chart-label">Overall Progress</span>
|
|
<canvas id="overall-chart-canvas" class="panel-chart-canvas" style="max-width: 200px; max-height: 200px;"></canvas>
|
|
<div id="overall-text" style="font-weight: bold; margin-top: 10px;">0/0 (0%)</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<h2>Progress by Control Panel</h2>
|
|
<div id="panels-progress">
|
|
<!-- Charts will be loaded here -->
|
|
<p>Loading panel data...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Bar -->
|
|
<div class="status-bar">
|
|
<span id="status-message">Initializing...</span> | Last Commit: <span id="last-commit">N/A</span>
|
|
</div>
|
|
|
|
<!-- Bootstrap Modal for Details -->
|
|
<div class="modal fade" id="detailsModal" tabindex="-1" aria-labelledby="detailsModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="detailsModalLabel">Details for Panel: <span></span></h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<table class="table table-sm table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Alias</th>
|
|
<th>Equipment Type</th>
|
|
<th>Type of Conveyor</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Missing/Found items will be populated here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
<script>
|
|
let chartInstances = {};
|
|
// Store the *entire* progress object now
|
|
let progressDetailsData = {};
|
|
let detailsModalInstance = null;
|
|
|
|
// --- Chart Click Handler ---
|
|
function handleChartClick(event, elements, chart) {
|
|
if (elements.length > 0) {
|
|
const clickedElementIndex = elements[0].index;
|
|
// Check if the clicked chart is the overall chart
|
|
const isOverallChart = chart.canvas.id === 'overall-chart-canvas';
|
|
// Determine panelName or use a special key for overall
|
|
const identifier = isOverallChart ? '__overall__' : chart.canvas.id.replace('chart-', '');
|
|
|
|
if (clickedElementIndex === 0) { // Clicked on 'Found' segment
|
|
showDetailsModal(identifier, 'found');
|
|
} else if (clickedElementIndex === 1) { // Clicked on 'Missing' segment
|
|
showDetailsModal(identifier, 'missing');
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateUI(data) {
|
|
console.log("Updating UI with data:", data);
|
|
|
|
// Store the entire progress object
|
|
progressDetailsData = data.progress;
|
|
|
|
// Update status
|
|
document.getElementById('status-message').textContent = data.status;
|
|
document.getElementById('last-commit').textContent = data.last_commit || 'N/A';
|
|
|
|
// Update overall progress chart
|
|
const overallData = progressDetailsData.overall; // Use stored data
|
|
const overallPercentage = overallData.percentage;
|
|
const overallFound = overallData.total_csv - overallData.missing;
|
|
const overallMissing = overallData.missing;
|
|
const overallTotal = overallData.total_csv;
|
|
document.getElementById('overall-text').textContent = `${overallFound}/${overallTotal} (${overallPercentage}%)`;
|
|
const overallChartData = { labels: ['Found', 'Missing'], datasets: [{ label: 'Overall Aliases', data: [overallFound, overallMissing], backgroundColor: ['rgb(25, 135, 84)', 'rgb(220, 53, 69)'], hoverOffset: 4 }] };
|
|
|
|
const overallCanvas = document.getElementById('overall-chart-canvas');
|
|
if (chartInstances['overall']) {
|
|
chartInstances['overall'].data = overallChartData;
|
|
chartInstances['overall'].update();
|
|
} else if (overallCanvas) {
|
|
const ctxOverall = overallCanvas.getContext('2d');
|
|
chartInstances['overall'] = new Chart(ctxOverall, {
|
|
type: 'pie', data: overallChartData,
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
onClick: handleChartClick, // <-- Add click handler to overall chart
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
let label = context.label || '';
|
|
if (label) label += ': ';
|
|
if (context.parsed !== null) label += context.parsed;
|
|
if (overallTotal > 0) { label += ` (${((context.parsed / overallTotal) * 100).toFixed(1)}%)`; }
|
|
return label;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update panel charts
|
|
const panelsContainer = document.getElementById('panels-progress');
|
|
const panelsData = progressDetailsData.panels; // Use stored data
|
|
const sortedPanels = Object.keys(panelsData).sort();
|
|
const currentPanels = new Set(sortedPanels);
|
|
const existingPanelCharts = new Set(Object.keys(chartInstances).filter(k => k !== 'overall'));
|
|
|
|
existingPanelCharts.forEach(panelName => {
|
|
if (!currentPanels.has(panelName)) {
|
|
if(chartInstances[panelName]) { chartInstances[panelName].destroy(); delete chartInstances[panelName]; }
|
|
const chartElement = document.getElementById(`chart-container-${panelName}`);
|
|
if (chartElement) chartElement.remove();
|
|
}
|
|
});
|
|
|
|
if (sortedPanels.length === 0) {
|
|
panelsContainer.innerHTML = '<p>No panel data available yet.</p>';
|
|
} else {
|
|
sortedPanels.forEach(panelName => {
|
|
const panel = panelsData[panelName];
|
|
const found = panel.total - panel.missing;
|
|
const missing = panel.missing;
|
|
const total = panel.total;
|
|
|
|
let chartContainer = document.getElementById(`chart-container-${panelName}`);
|
|
let canvas = document.getElementById(`chart-${panelName}`);
|
|
|
|
if (!chartContainer) {
|
|
chartContainer = document.createElement('div');
|
|
chartContainer.id = `chart-container-${panelName}`;
|
|
chartContainer.className = 'chart-container';
|
|
const label = document.createElement('span');
|
|
label.className = 'chart-label'; label.textContent = panelName;
|
|
canvas = document.createElement('canvas');
|
|
canvas.id = `chart-${panelName}`;
|
|
canvas.className = 'panel-chart-canvas';
|
|
chartContainer.appendChild(label);
|
|
chartContainer.appendChild(canvas);
|
|
panelsContainer.appendChild(chartContainer);
|
|
const loadingMsg = panelsContainer.querySelector('p');
|
|
if (loadingMsg && loadingMsg.textContent.includes('Loading')) { loadingMsg.remove(); }
|
|
} else {
|
|
if (!canvas) {
|
|
canvas = document.createElement('canvas');
|
|
canvas.id = `chart-${panelName}`;
|
|
canvas.className = 'panel-chart-canvas';
|
|
chartContainer.appendChild(canvas);
|
|
}
|
|
}
|
|
|
|
const chartData = { labels: ['Found', 'Missing'], datasets: [{ label: 'Aliases', data: [found, missing], backgroundColor: ['rgb(25, 135, 84)', 'rgb(220, 53, 69)'], hoverOffset: 4 }] };
|
|
|
|
if (chartInstances[panelName]) {
|
|
chartInstances[panelName].data = chartData;
|
|
chartInstances[panelName].update();
|
|
} else if (canvas) {
|
|
const ctx = canvas.getContext('2d');
|
|
chartInstances[panelName] = new Chart(ctx, {
|
|
type: 'pie',
|
|
data: chartData,
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
onClick: handleChartClick,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
callbacks: {
|
|
label: function(context) {
|
|
let label = context.label || '';
|
|
if (label) label += ': ';
|
|
if (context.parsed !== null) label += context.parsed;
|
|
if (total > 0) { label += ` (${((context.parsed / total) * 100).toFixed(1)}%)`; }
|
|
return label;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
const loadingMsgCheck = panelsContainer.querySelector('p');
|
|
if (loadingMsgCheck && loadingMsgCheck.textContent.includes('Loading')) {
|
|
loadingMsgCheck.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Updated function to handle overall chart clicks
|
|
function showDetailsModal(identifier, listType) {
|
|
let dataList = null;
|
|
let panelName = identifier; // Default to identifier being the panel name
|
|
const listTypeName = (listType === 'found') ? 'Found' : 'Missing';
|
|
|
|
if (identifier === '__overall__') {
|
|
// Handle overall chart click
|
|
const overallData = progressDetailsData.overall;
|
|
dataList = (listType === 'found') ? overallData.found_list : overallData.missing_list;
|
|
panelName = "Overall"; // Set display name for modal title
|
|
} else {
|
|
// Handle panel chart click
|
|
const panelData = progressDetailsData.panels[identifier]; // Use identifier as panelName
|
|
if (panelData) {
|
|
dataList = (listType === 'found') ? panelData.found_list : panelData.missing_list;
|
|
} else {
|
|
console.error("Panel data not found for identifier:", identifier);
|
|
return; // Exit if panel data doesn't exist
|
|
}
|
|
}
|
|
|
|
if (!dataList || dataList.length === 0) {
|
|
console.log(`No ${listTypeName} items to show for:`, panelName);
|
|
alert(`No ${listTypeName} items found for ${panelName}.`);
|
|
return;
|
|
}
|
|
|
|
const modalTitleElement = document.getElementById('detailsModalLabel');
|
|
const modalTitleSpan = modalTitleElement.querySelector('span');
|
|
const modalTableBody = document.querySelector('#detailsModal .modal-body tbody');
|
|
|
|
// Update modal title
|
|
modalTitleElement.childNodes[0].nodeValue = `${listTypeName} Items for ${panelName}: `;
|
|
modalTitleSpan.textContent = ""; // Clear the span if using the main text
|
|
// Or, if keeping the span: modalTitleSpan.textContent = panelName;
|
|
|
|
modalTableBody.innerHTML = ''; // Clear previous entries
|
|
|
|
dataList.forEach(item => {
|
|
const row = document.createElement('tr');
|
|
const aliasCell = document.createElement('td');
|
|
aliasCell.textContent = item.alias;
|
|
row.appendChild(aliasCell);
|
|
const eqTypeCell = document.createElement('td');
|
|
eqTypeCell.textContent = item.equipment_type || 'N/A';
|
|
row.appendChild(eqTypeCell);
|
|
const convTypeCell = document.createElement('td');
|
|
convTypeCell.textContent = item.conveyor_type || 'N/A';
|
|
row.appendChild(convTypeCell);
|
|
modalTableBody.appendChild(row);
|
|
});
|
|
|
|
if (!detailsModalInstance) {
|
|
detailsModalInstance = new bootstrap.Modal(document.getElementById('detailsModal'));
|
|
}
|
|
detailsModalInstance.show();
|
|
}
|
|
|
|
// --- Connect to SSE stream ---
|
|
const eventSource = new EventSource("/stream");
|
|
|
|
eventSource.onmessage = function(event) {
|
|
console.log("SSE message received:", event.data);
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
updateUI(data); // Call the UI update function with the new data
|
|
} catch (error) {
|
|
console.error("Error parsing SSE data:", error);
|
|
document.getElementById('status-message').textContent = 'Error processing update from server.';
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = function(err) {
|
|
console.error("EventSource failed:", err);
|
|
document.getElementById('status-message').textContent = 'Connection to server lost. Retrying...';
|
|
};
|
|
|
|
// Initial fetch remains the same
|
|
fetch('/stream').then(response => {
|
|
if (!response.ok) {
|
|
console.error("Initial fetch failed", response.statusText);
|
|
document.getElementById('status-message').textContent = 'Failed to fetch initial data.';
|
|
}
|
|
}).catch(err => {
|
|
console.error("Error during initial fetch for stream setup:", err);
|
|
document.getElementById('status-message').textContent = 'Error setting up data stream.';
|
|
});
|
|
|
|
</script>
|
|
</body>
|
|
</html> |