feat: Add comprehensive signal overview and dashboard improvements
- Add Signal Overview tab with protocol statistics and signal table - Fix button visibility issues with improved CSS styling - Add preconfigured Grafana dashboard with system monitoring - Add signal export functionality (CSV download) - Add protocol statistics cards (Modbus, OPC UA, Profinet, REST) - Add signal filtering and search capabilities - Update dashboard provisioning for Grafana - Add mock signal data for demonstration
This commit is contained in:
parent
d0a0c1c1d3
commit
90d3a650b0
|
|
@ -80,6 +80,7 @@ services:
|
|||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards
|
||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
||||
- ./monitoring/grafana/dashboard.yml:/etc/grafana/provisioning/dashboards/dashboard.yml
|
||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'default'
|
||||
orgId: 1
|
||||
folder: ''
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
{
|
||||
"dashboard": {
|
||||
"id": null,
|
||||
"title": "Calejo Control Adapter - System Overview",
|
||||
"tags": ["calejo", "control", "scada", "monitoring"],
|
||||
"timezone": "browser",
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "System Health",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "up{job=\"calejo-control\"}",
|
||||
"legendFormat": "{{instance}} - {{job}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{
|
||||
"color": "red",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "API Response Time",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(http_request_duration_seconds_sum{job=\"calejo-control\"}[5m]) / rate(http_request_duration_seconds_count{job=\"calejo-control\"}[5m])",
|
||||
"legendFormat": "Avg Response Time",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "HTTP Requests",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(http_requests_total{job=\"calejo-control\"}[5m])",
|
||||
"legendFormat": "{{method}} {{status}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Error Rate",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(http_requests_total{job=\"calejo-control\", status=~\"5..\"}[5m])",
|
||||
"legendFormat": "5xx Errors",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "rate(http_requests_total{job=\"calejo-control\", status=~\"4..\"}[5m])",
|
||||
"legendFormat": "4xx Errors",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Active Connections",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "scada_connections_active{job=\"calejo-control\"}",
|
||||
"legendFormat": "Active Connections",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Modbus Devices",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "scada_modbus_devices_total{job=\"calejo-control\"}",
|
||||
"legendFormat": "Modbus Devices",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 16
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "OPC UA Connections",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "scada_opcua_connections_active{job=\"calejo-control\"}",
|
||||
"legendFormat": "OPC UA Connections",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 8,
|
||||
"x": 16,
|
||||
"y": 16
|
||||
}
|
||||
}
|
||||
],
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"refresh": "5s",
|
||||
"schemaVersion": 35,
|
||||
"version": 0,
|
||||
"uid": "calejo-control-overview"
|
||||
},
|
||||
"folderUid": "",
|
||||
"message": "Calejo Control Adapter Dashboard",
|
||||
"overwrite": true
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ from .configuration_manager import (
|
|||
configuration_manager, OPCUAConfig, ModbusTCPConfig, PumpStationConfig,
|
||||
PumpConfig, SafetyLimitsConfig, DataPointMapping, ProtocolType
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -372,3 +373,116 @@ def save_configuration(config: SystemConfig):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving configuration: {str(e)}")
|
||||
|
||||
# Signal Overview endpoints
|
||||
@dashboard_router.get("/signals")
|
||||
async def get_signals():
|
||||
"""Get overview of all active signals across protocols"""
|
||||
try:
|
||||
# Mock data for demonstration - in real implementation, this would query active protocols
|
||||
signals = [
|
||||
{
|
||||
"name": "Temperature_Sensor_1",
|
||||
"protocol": "modbus",
|
||||
"address": "40001",
|
||||
"data_type": "Float32",
|
||||
"current_value": "23.5°C",
|
||||
"quality": "Good",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
},
|
||||
{
|
||||
"name": "Pressure_Sensor_1",
|
||||
"protocol": "modbus",
|
||||
"address": "40002",
|
||||
"data_type": "Float32",
|
||||
"current_value": "101.3 kPa",
|
||||
"quality": "Good",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
},
|
||||
{
|
||||
"name": "Flow_Rate_1",
|
||||
"protocol": "opcua",
|
||||
"address": "ns=2;s=FlowRate",
|
||||
"data_type": "Double",
|
||||
"current_value": "12.5 L/min",
|
||||
"quality": "Good",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
},
|
||||
{
|
||||
"name": "Valve_Position_1",
|
||||
"protocol": "profinet",
|
||||
"address": "DB1.DBX0.0",
|
||||
"data_type": "Bool",
|
||||
"current_value": "Open",
|
||||
"quality": "Good",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
},
|
||||
{
|
||||
"name": "Motor_Speed_1",
|
||||
"protocol": "rest",
|
||||
"address": "/api/v1/motors/1/speed",
|
||||
"data_type": "Int32",
|
||||
"current_value": "1450 RPM",
|
||||
"quality": "Good",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
]
|
||||
|
||||
protocol_stats = {
|
||||
"modbus": {
|
||||
"active_signals": 2,
|
||||
"total_signals": 5,
|
||||
"error_rate": "0%"
|
||||
},
|
||||
"opcua": {
|
||||
"active_signals": 1,
|
||||
"total_signals": 3,
|
||||
"error_rate": "0%"
|
||||
},
|
||||
"profinet": {
|
||||
"active_signals": 1,
|
||||
"total_signals": 2,
|
||||
"error_rate": "0%"
|
||||
},
|
||||
"rest": {
|
||||
"active_signals": 1,
|
||||
"total_signals": 4,
|
||||
"error_rate": "0%"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"signals": signals,
|
||||
"protocol_stats": protocol_stats,
|
||||
"total_signals": len(signals),
|
||||
"last_updated": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting signals: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get signals: {str(e)}")
|
||||
|
||||
@dashboard_router.get("/signals/export")
|
||||
async def export_signals():
|
||||
"""Export signals to CSV format"""
|
||||
try:
|
||||
# Get signals data
|
||||
signals_data = await get_signals()
|
||||
signals = signals_data["signals"]
|
||||
|
||||
# Create CSV content
|
||||
csv_content = "Signal Name,Protocol,Address,Data Type,Current Value,Quality,Timestamp\n"
|
||||
for signal in signals:
|
||||
csv_content += f'{signal["name"]},{signal["protocol"]},{signal["address"]},{signal["data_type"]},{signal["current_value"]},{signal["quality"]},{signal["timestamp"]}\n'
|
||||
|
||||
# Return as downloadable file
|
||||
from fastapi.responses import Response
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=calejo-signals.csv"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting signals: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to export signals: {str(e)}")
|
||||
|
|
@ -75,6 +75,7 @@ DASHBOARD_HTML = """
|
|||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
button:hover {
|
||||
background: #005a9e;
|
||||
|
|
@ -116,6 +117,7 @@ DASHBOARD_HTML = """
|
|||
color: #333;
|
||||
margin-right: 2px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-button.active {
|
||||
border-bottom-color: #007acc;
|
||||
|
|
@ -175,6 +177,7 @@ DASHBOARD_HTML = """
|
|||
<button class="tab-button active" onclick="showTab('status')">Status</button>
|
||||
<button class="tab-button" onclick="showTab('config')">Configuration</button>
|
||||
<button class="tab-button" onclick="showTab('scada')">SCADA/Hardware</button>
|
||||
<button class="tab-button" onclick="showTab('signals')">Signal Overview</button>
|
||||
<button class="tab-button" onclick="showTab('logs')">Logs</button>
|
||||
<button class="tab-button" onclick="showTab('actions')">Actions</button>
|
||||
</div>
|
||||
|
|
@ -361,6 +364,64 @@ DASHBOARD_HTML = """
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signal Overview Tab -->
|
||||
<div id="signals-tab" class="tab-content">
|
||||
<h2>Signal Overview</h2>
|
||||
<div id="signals-alerts"></div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3>Active Signals</h3>
|
||||
<div class="action-buttons">
|
||||
<button onclick="refreshSignals()">Refresh Signals</button>
|
||||
<button onclick="exportSignals()">Export to CSV</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<table style="width: 100%; border-collapse: collapse;" id="signals-table">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa;">
|
||||
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Signal Name</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Protocol</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Address/Node</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Data Type</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Current Value</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Quality</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; text-align: left;">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="signals-body">
|
||||
<!-- Signals will be populated by JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3>Protocol Statistics</h3>
|
||||
<div class="status-grid" id="protocol-stats-grid">
|
||||
<!-- Protocol statistics will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h3>Signal Configuration</h3>
|
||||
<div class="form-group">
|
||||
<label for="signal_filter">Filter Signals:</label>
|
||||
<input type="text" id="signal_filter" placeholder="Filter by name, protocol, or address..." style="width: 300px;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="protocol_filter">Protocol:</label>
|
||||
<select id="protocol_filter">
|
||||
<option value="">All Protocols</option>
|
||||
<option value="modbus">Modbus</option>
|
||||
<option value="opcua">OPC UA</option>
|
||||
<option value="profinet">Profinet</option>
|
||||
<option value="rest">REST API</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Tab -->
|
||||
<div id="logs-tab" class="tab-content">
|
||||
<h2>System Logs</h2>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ function showTab(tabName) {
|
|||
loadStatus();
|
||||
} else if (tabName === 'scada') {
|
||||
loadSCADAStatus();
|
||||
} else if (tabName === 'signals') {
|
||||
loadSignals();
|
||||
} else if (tabName === 'logs') {
|
||||
loadLogs();
|
||||
}
|
||||
|
|
@ -424,6 +426,80 @@ async function loadSCADAStatus() {
|
|||
}
|
||||
}
|
||||
|
||||
// Signal Overview functions
|
||||
async function loadSignals() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/dashboard/signals');
|
||||
const data = await response.json();
|
||||
|
||||
// Update protocol statistics
|
||||
const statsGrid = document.getElementById('protocol-stats-grid');
|
||||
statsGrid.innerHTML = '';
|
||||
|
||||
for (const [protocol, stats] of Object.entries(data.protocol_stats || {})) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'status-card running';
|
||||
card.innerHTML = `
|
||||
<h3>${protocol.toUpperCase()}</h3>
|
||||
<p>Active Signals: ${stats.active_signals || 0}</p>
|
||||
<p>Total Signals: ${stats.total_signals || 0}</p>
|
||||
<p>Error Rate: ${stats.error_rate || '0%'}</p>
|
||||
`;
|
||||
statsGrid.appendChild(card);
|
||||
}
|
||||
|
||||
// Update signals table
|
||||
const signalsBody = document.getElementById('signals-body');
|
||||
signalsBody.innerHTML = '';
|
||||
|
||||
data.signals.forEach(signal => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td style="padding: 10px; border: 1px solid #ddd;">${signal.name}</td>
|
||||
<td style="padding: 10px; border: 1px solid #ddd;">${signal.protocol}</td>
|
||||
<td style="padding: 10px; border: 1px solid #ddd;">${signal.address}</td>
|
||||
<td style="padding: 10px; border: 1px solid #ddd;">${signal.data_type}</td>
|
||||
<td style="padding: 10px; border: 1px solid #ddd;">${signal.current_value}</td>
|
||||
<td style="padding: 10px; border: 1px solid #ddd;">
|
||||
<span class="status-badge ${signal.quality === 'Good' ? 'running' : signal.quality === 'Bad' ? 'error' : 'warning'}">
|
||||
${signal.quality}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 10px; border: 1px solid #ddd;">${signal.timestamp}</td>
|
||||
`;
|
||||
signalsBody.appendChild(row);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading signals:', error);
|
||||
showAlert('Failed to load signal data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSignals() {
|
||||
await loadSignals();
|
||||
showAlert('Signals refreshed', 'success');
|
||||
}
|
||||
|
||||
async function exportSignals() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/dashboard/signals/export');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'calejo-signals.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
showAlert('Signals exported successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error exporting signals:', error);
|
||||
showAlert('Failed to export signals', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard on load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load initial status
|
||||
|
|
|
|||
Loading…
Reference in New Issue