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
|
- grafana_data:/var/lib/grafana
|
||||||
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards
|
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards
|
||||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
||||||
|
- ./monitoring/grafana/dashboard.yml:/etc/grafana/provisioning/dashboards/dashboard.yml
|
||||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
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,
|
configuration_manager, OPCUAConfig, ModbusTCPConfig, PumpStationConfig,
|
||||||
PumpConfig, SafetyLimitsConfig, DataPointMapping, ProtocolType
|
PumpConfig, SafetyLimitsConfig, DataPointMapping, ProtocolType
|
||||||
)
|
)
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -371,4 +372,117 @@ def save_configuration(config: SystemConfig):
|
||||||
# 3. Verify the new configuration
|
# 3. Verify the new configuration
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving configuration: {str(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;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
background: #005a9e;
|
background: #005a9e;
|
||||||
|
|
@ -116,6 +117,7 @@ DASHBOARD_HTML = """
|
||||||
color: #333;
|
color: #333;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.tab-button.active {
|
.tab-button.active {
|
||||||
border-bottom-color: #007acc;
|
border-bottom-color: #007acc;
|
||||||
|
|
@ -175,6 +177,7 @@ DASHBOARD_HTML = """
|
||||||
<button class="tab-button active" onclick="showTab('status')">Status</button>
|
<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('config')">Configuration</button>
|
||||||
<button class="tab-button" onclick="showTab('scada')">SCADA/Hardware</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('logs')">Logs</button>
|
||||||
<button class="tab-button" onclick="showTab('actions')">Actions</button>
|
<button class="tab-button" onclick="showTab('actions')">Actions</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -361,6 +364,64 @@ DASHBOARD_HTML = """
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Logs Tab -->
|
||||||
<div id="logs-tab" class="tab-content">
|
<div id="logs-tab" class="tab-content">
|
||||||
<h2>System Logs</h2>
|
<h2>System Logs</h2>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ function showTab(tabName) {
|
||||||
loadStatus();
|
loadStatus();
|
||||||
} else if (tabName === 'scada') {
|
} else if (tabName === 'scada') {
|
||||||
loadSCADAStatus();
|
loadSCADAStatus();
|
||||||
|
} else if (tabName === 'signals') {
|
||||||
|
loadSignals();
|
||||||
} else if (tabName === 'logs') {
|
} else if (tabName === 'logs') {
|
||||||
loadLogs();
|
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
|
// Initialize dashboard on load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Load initial status
|
// Load initial status
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue