release
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
data/rrd/*.rrd
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
24
.env.example
Normal file
24
.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# .env.example - Template for environment variables
|
||||||
|
# Copy to .env and customize
|
||||||
|
|
||||||
|
# GPON Device
|
||||||
|
GPON_HOST=192.168.100.1
|
||||||
|
GPON_PORT=23
|
||||||
|
GPON_USERNAME=admin
|
||||||
|
GPON_PASSWORD=admin
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
LISTEN_HOST=0.0.0.0
|
||||||
|
LISTEN_PORT=8080
|
||||||
|
EXTERNAL_PORT=7878
|
||||||
|
|
||||||
|
# Data
|
||||||
|
RRD_DIR=/data/rrd
|
||||||
|
POLL_INTERVAL=60
|
||||||
|
|
||||||
|
# Thresholds
|
||||||
|
RX_POWER_MIN=-28.0
|
||||||
|
RX_POWER_MAX=-8.0
|
||||||
|
TX_POWER_MIN=0.5
|
||||||
|
TX_POWER_MAX=4.0
|
||||||
|
TEMPERATURE_MAX=85.0
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
env
|
||||||
|
venv
|
||||||
|
.env
|
||||||
|
/data/*
|
||||||
|
__pycache__
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
rrdtool \
|
||||||
|
rrdtool-dev \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
python3-dev \
|
||||||
|
libffi-dev \
|
||||||
|
openssl-dev \
|
||||||
|
wget
|
||||||
|
|
||||||
|
# Copy requirements first (better caching)
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app.py .
|
||||||
|
COPY config.py .
|
||||||
|
COPY collector.py .
|
||||||
|
COPY rrd_manager.py .
|
||||||
|
|
||||||
|
# Copy static files and templates
|
||||||
|
COPY static/ ./static/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
|
||||||
|
# Create RRD directory
|
||||||
|
RUN mkdir -p /data/rrd
|
||||||
|
|
||||||
|
ENV LISTEN_PORT=8080
|
||||||
|
EXPOSE $LISTEN_PORT
|
||||||
|
|
||||||
|
# Run app
|
||||||
|
CMD ["python", "app.py"]
|
||||||
190
README.md
Normal file
190
README.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# GPON Monitor
|
||||||
|
|
||||||
|
Real-time monitoring for ONT/ONU GPON devices via OMCI telnet and web scraping with RRD-based historical graphs.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone or download the project
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd gpon-monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure .env file
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit basic parameters:
|
||||||
|
```env
|
||||||
|
GPON_HOST=192.168.100.1
|
||||||
|
GPON_USERNAME=admin
|
||||||
|
GPON_PASSWORD=admin
|
||||||
|
EXTERNAL_PORT=8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the container
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Open in browser
|
||||||
|
```
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Real-time metrics: RX/TX optical power, temperature, uptime
|
||||||
|
- Historical graphs: 1 hour to 5 years
|
||||||
|
- Statistics: packets, bytes, FEC errors, data volume
|
||||||
|
- Alerts: optical power threshold monitoring
|
||||||
|
- Prometheus metrics endpoint
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker-compose build
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Remove everything including RRD data
|
||||||
|
docker-compose down -v
|
||||||
|
rm -rf data/rrd/*
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
leox-gpon-monitoring/
|
||||||
|
├── .env # Configuration (HOST, PORT, credentials)
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── Dockerfile
|
||||||
|
├── data/rrd/ # RRD data files (persistent volume)
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Change web port
|
||||||
|
```env
|
||||||
|
EXTERNAL_PORT=80 # External web port (default: 8080)
|
||||||
|
LISTEN_PORT=8080 # Internal port (usually no need to change)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change polling interval
|
||||||
|
```env
|
||||||
|
POLL_INTERVAL=60 # Seconds (default: 60s)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alert thresholds
|
||||||
|
```env
|
||||||
|
RX_POWER_MIN=-28.0
|
||||||
|
RX_POWER_MAX=-8.0
|
||||||
|
TX_POWER_MIN=0.5
|
||||||
|
TX_POWER_MAX=4.0
|
||||||
|
TEMPERATURE_MAX=85.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Details
|
||||||
|
|
||||||
|
- Base image: Python 3.11 Alpine
|
||||||
|
- Volumes: `./data/rrd:/data/rrd`
|
||||||
|
- Network: Bridge
|
||||||
|
- Healthcheck: `/api/current` endpoint
|
||||||
|
- Restart policy: unless-stopped
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Current data
|
||||||
|
curl http://localhost:8080/api/current
|
||||||
|
|
||||||
|
# Optical history (RX/TX power, temperature)
|
||||||
|
curl http://localhost:8080/api/history/optical/24h
|
||||||
|
|
||||||
|
# Traffic history (packets, bytes)
|
||||||
|
curl http://localhost:8080/api/history/traffic/7d
|
||||||
|
|
||||||
|
# FEC errors history
|
||||||
|
curl http://localhost:8080/api/history/fec/1h
|
||||||
|
|
||||||
|
# Prometheus metrics
|
||||||
|
curl http://localhost:8080/metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
Available periods: `1h`, `6h`, `12h`, `24h`, `3d`, `7d`, `14d`, `30d`, `60d`, `90d`, `120d`, `1y`, `2y`, `5y`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No connection to ONT
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Verify ONT is reachable
|
||||||
|
ping 192.168.100.1
|
||||||
|
|
||||||
|
# Test telnet connection
|
||||||
|
telnet 192.168.100.1 23
|
||||||
|
```
|
||||||
|
|
||||||
|
### No data on graphs
|
||||||
|
```bash
|
||||||
|
# Check if RRD files are created
|
||||||
|
ls -lah data/rrd/
|
||||||
|
|
||||||
|
# Wait 2-3 minutes for initial data collection
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset all data
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
rm -rf data/rrd/*.rrd
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
- ONT device with telnet access (port 23)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Development mode with hot-reload
|
||||||
|
```bash
|
||||||
|
docker run -it --rm \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v $(pwd):/app \
|
||||||
|
-v $(pwd)/data/rrd:/data/rrd \
|
||||||
|
--env-file .env \
|
||||||
|
gpon-monitor:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual build
|
||||||
|
```bash
|
||||||
|
docker build -t gpon-monitor:latest .
|
||||||
|
docker run -d -p 8080:8080 --env-file .env gpon-monitor:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
linuxiarz.pl Mateusz Gruszczyński
|
||||||
179
app.py
Normal file
179
app.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
from flask import Flask, render_template, jsonify, Response
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from collector import GPONCollector
|
||||||
|
from rrd_manager import RRDManager
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
collector = GPONCollector(Config)
|
||||||
|
rrd_manager = RRDManager(Config.RRD_DIR)
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', config=Config)
|
||||||
|
|
||||||
|
@app.route('/api/current')
|
||||||
|
def api_current():
|
||||||
|
data = collector.get_data()
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
@app.route('/api/history/<metric>/<period>')
|
||||||
|
def history(metric, period):
|
||||||
|
valid_metrics = ['optical', 'traffic', 'fec']
|
||||||
|
valid_periods = ['1h', '6h', '12h', '24h', '3d', '7d', '14d', '30d',
|
||||||
|
'60d', '90d', '120d', '1y', '2y', '5y']
|
||||||
|
|
||||||
|
if metric not in valid_metrics:
|
||||||
|
return jsonify({'error': 'Invalid metric'}), 400
|
||||||
|
|
||||||
|
if period not in valid_periods:
|
||||||
|
return jsonify({'error': f'Invalid period. Valid: {valid_periods}'}), 400
|
||||||
|
|
||||||
|
data = rrd_manager.fetch(metric, period)
|
||||||
|
if data:
|
||||||
|
return jsonify(data)
|
||||||
|
return jsonify({'error': 'No data'}), 404
|
||||||
|
|
||||||
|
@app.route('/api/alerts')
|
||||||
|
def api_alerts():
|
||||||
|
alerts = collector.get_alerts()
|
||||||
|
return jsonify(alerts)
|
||||||
|
|
||||||
|
@app.route('/metrics')
|
||||||
|
def prometheus_metrics():
|
||||||
|
data = collector.get_data()
|
||||||
|
metrics = []
|
||||||
|
|
||||||
|
host = Config.GPON_HOST
|
||||||
|
|
||||||
|
if data.get('rx_power') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_rx_power_dbm GPON RX optical power in dBm')
|
||||||
|
metrics.append(f'# TYPE gpon_rx_power_dbm gauge')
|
||||||
|
metrics.append(f'gpon_rx_power_dbm{{host="{host}"}} {data["rx_power"]:.3f}')
|
||||||
|
|
||||||
|
if data.get('tx_power') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_tx_power_dbm GPON TX optical power in dBm')
|
||||||
|
metrics.append(f'# TYPE gpon_tx_power_dbm gauge')
|
||||||
|
metrics.append(f'gpon_tx_power_dbm{{host="{host}"}} {data["tx_power"]:.3f}')
|
||||||
|
|
||||||
|
if data.get('voltage') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_voltage_volts GPON supply voltage in volts')
|
||||||
|
metrics.append(f'# TYPE gpon_voltage_volts gauge')
|
||||||
|
metrics.append(f'gpon_voltage_volts{{host="{host}"}} {data["voltage"]:.3f}')
|
||||||
|
|
||||||
|
if data.get('tx_bias_current') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_tx_bias_ma GPON TX bias current in mA')
|
||||||
|
metrics.append(f'# TYPE gpon_tx_bias_ma gauge')
|
||||||
|
metrics.append(f'gpon_tx_bias_ma{{host="{host}"}} {data["tx_bias_current"]:.2f}')
|
||||||
|
|
||||||
|
if data.get('temperature') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_temperature_celsius GPON temperature in Celsius')
|
||||||
|
metrics.append(f'# TYPE gpon_temperature_celsius gauge')
|
||||||
|
metrics.append(f'gpon_temperature_celsius{{host="{host}"}} {data["temperature"]:.2f}')
|
||||||
|
|
||||||
|
if data.get('uptime') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_uptime_seconds GPON uptime in seconds')
|
||||||
|
metrics.append(f'# TYPE gpon_uptime_seconds gauge')
|
||||||
|
metrics.append(f'gpon_uptime_seconds{{host="{host}"}} {data["uptime"]}')
|
||||||
|
|
||||||
|
status_value = 1 if data.get('status') == 'online' else 0
|
||||||
|
metrics.append(f'# HELP gpon_status GPON status (1=online, 0=offline)')
|
||||||
|
metrics.append(f'# TYPE gpon_status gauge')
|
||||||
|
metrics.append(f'gpon_status{{host="{host}"}} {status_value}')
|
||||||
|
|
||||||
|
if data.get('rx_packets') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_rx_packets_total Total RX packets')
|
||||||
|
metrics.append(f'# TYPE gpon_rx_packets_total counter')
|
||||||
|
metrics.append(f'gpon_rx_packets_total{{host="{host}"}} {data["rx_packets"]}')
|
||||||
|
|
||||||
|
if data.get('tx_packets') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_tx_packets_total Total TX packets')
|
||||||
|
metrics.append(f'# TYPE gpon_tx_packets_total counter')
|
||||||
|
metrics.append(f'gpon_tx_packets_total{{host="{host}"}} {data["tx_packets"]}')
|
||||||
|
|
||||||
|
if data.get('rx_bytes') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_rx_bytes_total Total RX bytes')
|
||||||
|
metrics.append(f'# TYPE gpon_rx_bytes_total counter')
|
||||||
|
metrics.append(f'gpon_rx_bytes_total{{host="{host}"}} {data["rx_bytes"]}')
|
||||||
|
|
||||||
|
if data.get('tx_bytes') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_tx_bytes_total Total TX bytes')
|
||||||
|
metrics.append(f'# TYPE gpon_tx_bytes_total counter')
|
||||||
|
metrics.append(f'gpon_tx_bytes_total{{host="{host}"}} {data["tx_bytes"]}')
|
||||||
|
|
||||||
|
if data.get('fec_corrected') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_fec_corrected_total Total FEC corrected errors')
|
||||||
|
metrics.append(f'# TYPE gpon_fec_corrected_total counter')
|
||||||
|
metrics.append(f'gpon_fec_corrected_total{{host="{host}"}} {data["fec_corrected"]}')
|
||||||
|
|
||||||
|
if data.get('fec_uncorrected') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_fec_uncorrected_total Total FEC uncorrected errors')
|
||||||
|
metrics.append(f'# TYPE gpon_fec_uncorrected_total counter')
|
||||||
|
metrics.append(f'gpon_fec_uncorrected_total{{host="{host}"}} {data["fec_uncorrected"]}')
|
||||||
|
|
||||||
|
if data.get('fec_total_codewords') is not None:
|
||||||
|
metrics.append(f'# HELP gpon_fec_codewords_total Total FEC codewords')
|
||||||
|
metrics.append(f'# TYPE gpon_fec_codewords_total counter')
|
||||||
|
metrics.append(f'gpon_fec_codewords_total{{host="{host}"}} {data["fec_total_codewords"]}')
|
||||||
|
|
||||||
|
vendor = data.get('vendor_id', 'unknown').replace('"', '\\"')
|
||||||
|
serial = data.get('serial_number', 'unknown').replace('"', '\\"')
|
||||||
|
version = data.get('version', 'unknown').replace('"', '\\"')
|
||||||
|
mac = data.get('mac_address', 'unknown').replace('"', '\\"')
|
||||||
|
|
||||||
|
metrics.append(f'# HELP gpon_device_info GPON device information')
|
||||||
|
metrics.append(f'# TYPE gpon_device_info gauge')
|
||||||
|
metrics.append(f'gpon_device_info{{host="{host}",vendor="{vendor}",serial="{serial}",version="{version}",mac="{mac}"}} 1')
|
||||||
|
|
||||||
|
olt_vendor = data.get('olt_vendor_info', 'unknown').replace('"', '\\"')
|
||||||
|
olt_version = data.get('olt_version_info', 'unknown').replace('"', '\\"')
|
||||||
|
|
||||||
|
metrics.append(f'# HELP gpon_olt_info OLT information')
|
||||||
|
metrics.append(f'# TYPE gpon_olt_info gauge')
|
||||||
|
metrics.append(f'gpon_olt_info{{host="{host}",olt_vendor="{olt_vendor}",olt_version="{olt_version}"}} 1')
|
||||||
|
|
||||||
|
return Response('\n'.join(metrics) + '\n', mimetype='text/plain')
|
||||||
|
|
||||||
|
def update_rrd_loop():
|
||||||
|
import time
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = collector.get_data()
|
||||||
|
if data:
|
||||||
|
rrd_manager.update(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"RRD update error: {e}")
|
||||||
|
|
||||||
|
time.sleep(Config.POLL_INTERVAL)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info("GPON Monitor - Starting")
|
||||||
|
logger.info(f"Host: {Config.GPON_HOST}:{Config.GPON_PORT}")
|
||||||
|
logger.info(f"Web: http://{Config.LISTEN_HOST}:{Config.LISTEN_PORT}")
|
||||||
|
logger.info(f"RRD Directory: {Config.RRD_DIR}")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
|
||||||
|
collector.start()
|
||||||
|
|
||||||
|
from threading import Thread
|
||||||
|
rrd_thread = Thread(target=update_rrd_loop, daemon=True)
|
||||||
|
rrd_thread.start()
|
||||||
|
|
||||||
|
app.run(
|
||||||
|
host=Config.LISTEN_HOST,
|
||||||
|
port=Config.LISTEN_PORT,
|
||||||
|
debug=False,
|
||||||
|
threaded=True
|
||||||
|
)
|
||||||
386
collector.py
Normal file
386
collector.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
from aiohttp import BasicAuth
|
||||||
|
import telnetlib3
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from threading import Thread, Lock
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class GPONCollector:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.data = {}
|
||||||
|
self.lock = Lock()
|
||||||
|
self.running = False
|
||||||
|
self.thread = None
|
||||||
|
self.alerts = []
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
self.thread = Thread(target=self._collect_loop, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
logger.info("Collector started")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
if self.thread:
|
||||||
|
self.thread.join(timeout=5)
|
||||||
|
logger.info("Collector stopped")
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
with self.lock:
|
||||||
|
return self.data.copy()
|
||||||
|
|
||||||
|
def get_alerts(self):
|
||||||
|
with self.lock:
|
||||||
|
return self.alerts.copy()
|
||||||
|
|
||||||
|
def _collect_loop(self):
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
data = loop.run_until_complete(self._collect_all_data())
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
if data and data.get('status') == 'online':
|
||||||
|
with self.lock:
|
||||||
|
self.data = data
|
||||||
|
self._check_alerts(data)
|
||||||
|
|
||||||
|
rx = data.get('rx_power', 0)
|
||||||
|
tx = data.get('tx_power', 0)
|
||||||
|
temp = data.get('temperature', 0)
|
||||||
|
uptime = data.get('uptime', 0)
|
||||||
|
uptime_days = uptime // 86400
|
||||||
|
logger.info(f"Collected: RX={rx:.2f}dBm TX={tx:.2f}dBm Temp={temp:.1f}C Uptime={uptime}s ({uptime_days}d)")
|
||||||
|
else:
|
||||||
|
logger.warning("No data or device offline")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in collector loop: {e}", exc_info=True)
|
||||||
|
|
||||||
|
time.sleep(self.config.POLL_INTERVAL)
|
||||||
|
|
||||||
|
async def _collect_all_data(self):
|
||||||
|
omci_data = await self._collect_omci_async()
|
||||||
|
|
||||||
|
if not omci_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
web_data = await self._scrape_web_interface()
|
||||||
|
|
||||||
|
if web_data:
|
||||||
|
for key, value in web_data.items():
|
||||||
|
if value is not None:
|
||||||
|
omci_data[key] = value
|
||||||
|
|
||||||
|
return omci_data
|
||||||
|
|
||||||
|
async def _scrape_web_interface(self):
|
||||||
|
try:
|
||||||
|
auth = BasicAuth(self.config.GPON_USERNAME, self.config.GPON_PASSWORD)
|
||||||
|
base_url = f'http://{self.config.GPON_HOST}'
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
connector = aiohttp.TCPConnector(ssl=False)
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10, connect=5)
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f'{base_url}/status_pon.asp'
|
||||||
|
async with session.get(url, auth=auth) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
html = await response.text()
|
||||||
|
|
||||||
|
temp_match = re.search(r'<th[^>]*>Temperature</th>\s*<td[^>]*>([\d.]+)\s*C', html, re.IGNORECASE)
|
||||||
|
if temp_match:
|
||||||
|
data['temperature'] = float(temp_match.group(1))
|
||||||
|
logger.debug(f"[WEB] Temperature: {data['temperature']}")
|
||||||
|
|
||||||
|
volt_match = re.search(r'<th[^>]*>Voltage</th>\s*<td[^>]*>([\d.]+)\s*V', html, re.IGNORECASE)
|
||||||
|
if volt_match:
|
||||||
|
data['voltage'] = float(volt_match.group(1))
|
||||||
|
logger.debug(f"[WEB] Voltage: {data['voltage']}")
|
||||||
|
|
||||||
|
bias_match = re.search(r'<th[^>]*>Bias Current</th>\s*<td[^>]*>([\d.]+)\s*mA', html, re.IGNORECASE)
|
||||||
|
if bias_match:
|
||||||
|
data['tx_bias_current'] = float(bias_match.group(1))
|
||||||
|
logger.debug(f"[WEB] TX Bias: {data['tx_bias_current']}")
|
||||||
|
|
||||||
|
logger.info(f"[WEB] status_pon.asp OK: temp={data.get('temperature')} voltage={data.get('voltage')} bias={data.get('tx_bias_current')}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[WEB] status_pon.asp returned {response.status}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WEB] Error fetching status_pon.asp: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f'{base_url}/status.asp'
|
||||||
|
async with session.get(url, auth=auth) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
html = await response.text()
|
||||||
|
|
||||||
|
uptime_match = re.search(
|
||||||
|
r'<th[^>]*>Uptime</th>\s*<td[^>]*>\s*([^<]+)</td>',
|
||||||
|
html,
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
if uptime_match:
|
||||||
|
uptime_str = uptime_match.group(1).strip()
|
||||||
|
logger.debug(f"[WEB] Raw uptime: '{uptime_str}'")
|
||||||
|
|
||||||
|
days_match = re.search(r'(\d+)\s*days?,\s*(\d+):(\d+)', uptime_str)
|
||||||
|
if days_match:
|
||||||
|
days = int(days_match.group(1))
|
||||||
|
hours = int(days_match.group(2))
|
||||||
|
minutes = int(days_match.group(3))
|
||||||
|
data['uptime'] = (days * 86400) + (hours * 3600) + (minutes * 60)
|
||||||
|
logger.info(f"[WEB] Uptime: {days}d {hours}h {minutes}m = {data['uptime']}s")
|
||||||
|
else:
|
||||||
|
time_match = re.search(r'(\d+):(\d+)', uptime_str)
|
||||||
|
if time_match:
|
||||||
|
hours = int(time_match.group(1))
|
||||||
|
minutes = int(time_match.group(2))
|
||||||
|
data['uptime'] = (hours * 3600) + (minutes * 60)
|
||||||
|
logger.info(f"[WEB] Uptime: {hours}h {minutes}m = {data['uptime']}s")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[WEB] Could not parse uptime: '{uptime_str}'")
|
||||||
|
else:
|
||||||
|
logger.warning("[WEB] Uptime not found in status.asp")
|
||||||
|
|
||||||
|
logger.info(f"[WEB] status.asp OK: uptime={data.get('uptime')}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[WEB] status.asp returned {response.status}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WEB] Error fetching status.asp: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f'{base_url}/admin/pon-stats.asp'
|
||||||
|
async with session.get(url, auth=auth) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
html = await response.text()
|
||||||
|
|
||||||
|
tx_pkts_match = re.search(r'<th[^>]*>Packets Sent:</th>\s*<td[^>]*>(\d+)</td>', html, re.IGNORECASE)
|
||||||
|
if tx_pkts_match:
|
||||||
|
data['tx_packets'] = int(tx_pkts_match.group(1))
|
||||||
|
logger.debug(f"[WEB] TX Packets: {data['tx_packets']}")
|
||||||
|
|
||||||
|
rx_pkts_match = re.search(r'<th[^>]*>Packets Received:</th>\s*<td[^>]*>(\d+)</td>', html, re.IGNORECASE)
|
||||||
|
if rx_pkts_match:
|
||||||
|
data['rx_packets'] = int(rx_pkts_match.group(1))
|
||||||
|
logger.debug(f"[WEB] RX Packets: {data['rx_packets']}")
|
||||||
|
|
||||||
|
tx_bytes_match = re.search(r'<th[^>]*>Bytes Sent:</th>\s*<td[^>]*>(\d+)</td>', html, re.IGNORECASE)
|
||||||
|
if tx_bytes_match:
|
||||||
|
data['tx_bytes'] = int(tx_bytes_match.group(1))
|
||||||
|
logger.debug(f"[WEB] TX Bytes: {data['tx_bytes']}")
|
||||||
|
|
||||||
|
rx_bytes_match = re.search(r'<th[^>]*>Bytes Received:</th>\s*<td[^>]*>(\d+)</td>', html, re.IGNORECASE)
|
||||||
|
if rx_bytes_match:
|
||||||
|
data['rx_bytes'] = int(rx_bytes_match.group(1))
|
||||||
|
logger.debug(f"[WEB] RX Bytes: {data['rx_bytes']}")
|
||||||
|
|
||||||
|
logger.info(f"[WEB] pon-stats.asp OK: rx_pkts={data.get('rx_packets')} tx_pkts={data.get('tx_packets')}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[WEB] pon-stats.asp returned {response.status}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WEB] Error fetching pon-stats.asp: {e}")
|
||||||
|
|
||||||
|
if data:
|
||||||
|
logger.info(f"[WEB] Scraped {len(data)} fields from web interface")
|
||||||
|
else:
|
||||||
|
logger.warning("[WEB] No data scraped from web interface")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WEB] Web scraping failed: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def _collect_omci_async(self):
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
telnetlib3.open_connection(
|
||||||
|
self.config.GPON_HOST,
|
||||||
|
self.config.GPON_PORT
|
||||||
|
),
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.wait_for(reader.readuntil(b'login: '), timeout=5)
|
||||||
|
writer.write(self.config.GPON_USERNAME + '\n')
|
||||||
|
|
||||||
|
await asyncio.wait_for(reader.readuntil(b'Password: '), timeout=5)
|
||||||
|
writer.write(self.config.GPON_PASSWORD + '\n')
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
initial_output = await reader.read(2048)
|
||||||
|
|
||||||
|
if 'RTK.0>' in initial_output or 'command:#' in initial_output:
|
||||||
|
writer.write('exit\n')
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await reader.read(1024)
|
||||||
|
|
||||||
|
writer.write('omcicli mib get all\n')
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
full_output = await reader.read(102400)
|
||||||
|
|
||||||
|
writer.write('exit\n')
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug(f"[OMCI] Received {len(full_output)} bytes")
|
||||||
|
|
||||||
|
return self._parse_omci(full_output)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("[OMCI] Timeout during telnet connection")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[OMCI] Telnet error: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_omci(self, raw_data):
|
||||||
|
try:
|
||||||
|
with open('/tmp/omci_debug.txt', 'w') as f:
|
||||||
|
f.write(raw_data)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'status': 'offline',
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
match = re.search(r'OpticalSignalLevel:\s*(0x[0-9a-fA-F]+)', raw_data)
|
||||||
|
if match:
|
||||||
|
rx_hex = int(match.group(1), 16)
|
||||||
|
data['rx_power'] = self._convert_optical_power(rx_hex)
|
||||||
|
|
||||||
|
match = re.search(r'TranOpticLevel:\s*(0x[0-9a-fA-F]+)', raw_data)
|
||||||
|
if match:
|
||||||
|
tx_hex = int(match.group(1), 16)
|
||||||
|
data['tx_power'] = self._convert_optical_power(tx_hex)
|
||||||
|
|
||||||
|
match = re.search(r'SerialNum:\s*(\S+)', raw_data)
|
||||||
|
if match:
|
||||||
|
data['serial_number'] = match.group(1)
|
||||||
|
|
||||||
|
match = re.search(r'Version:\s*(M\d+\S*)', raw_data)
|
||||||
|
if match:
|
||||||
|
data['version'] = match.group(1)
|
||||||
|
|
||||||
|
match = re.search(r'VID:\s*(\S+)', raw_data)
|
||||||
|
if match:
|
||||||
|
data['vendor_id'] = match.group(1)
|
||||||
|
|
||||||
|
match = re.search(r'OltVendorId:\s*(\S+)', raw_data)
|
||||||
|
if match:
|
||||||
|
data['olt_vendor_info'] = match.group(1)
|
||||||
|
|
||||||
|
match = re.search(r'OltG.*?Version:\s*(\d+)', raw_data, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
data['olt_version_info'] = match.group(1)
|
||||||
|
|
||||||
|
match = re.search(r'MacAddress:\s*([0-9a-fA-F:]+)', raw_data)
|
||||||
|
if match:
|
||||||
|
data['mac_address'] = match.group(1)
|
||||||
|
|
||||||
|
fec_section = re.search(
|
||||||
|
r'FecPmhd.*?CorCodeWords:\s*(0x[0-9a-fA-F]+).*?UncorCodeWords:\s*(0x[0-9a-fA-F]+)',
|
||||||
|
raw_data,
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
if fec_section:
|
||||||
|
data['fec_corrected'] = int(fec_section.group(1), 16)
|
||||||
|
data['fec_uncorrected'] = int(fec_section.group(2), 16)
|
||||||
|
else:
|
||||||
|
data['fec_corrected'] = 0
|
||||||
|
data['fec_uncorrected'] = 0
|
||||||
|
|
||||||
|
if data.get('rx_power') is not None and data.get('tx_power') is not None:
|
||||||
|
data['status'] = 'online'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[OMCI] Parsing error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _convert_optical_power(self, hex_value):
|
||||||
|
if hex_value > 0x7fff:
|
||||||
|
signed_value = hex_value - 0x10000
|
||||||
|
else:
|
||||||
|
signed_value = hex_value
|
||||||
|
return signed_value * 0.002
|
||||||
|
|
||||||
|
def _check_alerts(self, data):
|
||||||
|
new_alerts = []
|
||||||
|
thresholds = self.config.THRESHOLDS
|
||||||
|
|
||||||
|
rx = data.get('rx_power')
|
||||||
|
if rx is not None:
|
||||||
|
if rx < thresholds['rx_power_min']:
|
||||||
|
new_alerts.append({
|
||||||
|
'severity': 'critical',
|
||||||
|
'category': 'optical',
|
||||||
|
'message': f'RX Power critically low: {rx:.2f} dBm',
|
||||||
|
'value': rx,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
elif rx > thresholds['rx_power_max']:
|
||||||
|
new_alerts.append({
|
||||||
|
'severity': 'warning',
|
||||||
|
'category': 'optical',
|
||||||
|
'message': f'RX Power high: {rx:.2f} dBm',
|
||||||
|
'value': rx,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
tx = data.get('tx_power')
|
||||||
|
if tx is not None:
|
||||||
|
if tx < thresholds['tx_power_min'] or tx > thresholds['tx_power_max']:
|
||||||
|
new_alerts.append({
|
||||||
|
'severity': 'warning',
|
||||||
|
'category': 'optical',
|
||||||
|
'message': f'TX Power out of range: {tx:.2f} dBm',
|
||||||
|
'value': tx,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
temp = data.get('temperature')
|
||||||
|
if temp is not None and temp > thresholds.get('temperature_max', 85):
|
||||||
|
new_alerts.append({
|
||||||
|
'severity': 'warning',
|
||||||
|
'category': 'temperature',
|
||||||
|
'message': f'Temperature high: {temp:.1f}C',
|
||||||
|
'value': temp,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
fec_uncor = data.get('fec_uncorrected', 0)
|
||||||
|
if fec_uncor > 0:
|
||||||
|
new_alerts.append({
|
||||||
|
'severity': 'critical',
|
||||||
|
'category': 'transmission',
|
||||||
|
'message': f'FEC Uncorrected Errors: {fec_uncor}',
|
||||||
|
'value': fec_uncor,
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.alerts = new_alerts[-20:]
|
||||||
|
|
||||||
|
if new_alerts:
|
||||||
|
logger.warning(f"Generated {len(new_alerts)} alerts")
|
||||||
33
config.py
Normal file
33
config.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# Flask
|
||||||
|
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||||
|
|
||||||
|
# GPON Device
|
||||||
|
GPON_HOST = os.getenv('GPON_HOST', '192.168.100.1')
|
||||||
|
GPON_PORT = int(os.getenv('GPON_PORT', 23))
|
||||||
|
GPON_USERNAME = os.getenv('GPON_USERNAME', 'leox')
|
||||||
|
GPON_PASSWORD = os.getenv('GPON_PASSWORD', 'leolabs_7')
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
POLL_INTERVAL = int(os.getenv('POLL_INTERVAL', 60)) # seconds
|
||||||
|
|
||||||
|
# RRD Database
|
||||||
|
RRD_DIR = Path(os.getenv('RRD_DIR', './data/rrd'))
|
||||||
|
RRD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Thresholds
|
||||||
|
THRESHOLDS = {
|
||||||
|
'rx_power_min': float(os.getenv('RX_POWER_MIN', -28.0)),
|
||||||
|
'rx_power_max': float(os.getenv('RX_POWER_MAX', -8.0)),
|
||||||
|
'tx_power_min': float(os.getenv('TX_POWER_MIN', 0.0)),
|
||||||
|
'tx_power_max': float(os.getenv('TX_POWER_MAX', 5.0)),
|
||||||
|
'temperature_max': float(os.getenv('TEMPERATURE_MAX', 85.0)),
|
||||||
|
'fec_errors_max': int(os.getenv('FEC_ERRORS_MAX', 1000)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Web Interface
|
||||||
|
LISTEN_HOST = os.getenv('LISTEN_HOST', '0.0.0.0')
|
||||||
|
LISTEN_PORT = int(os.getenv('LISTEN_PORT', 8080))
|
||||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
gpon-monitor:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
LISTEN_PORT: ${LISTEN_PORT:-8080}
|
||||||
|
container_name: gpon-monitor
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${EXTERNAL_PORT:-8080}:${LISTEN_PORT:-8080}"
|
||||||
|
volumes:
|
||||||
|
- ./data/rrd:/data/rrd
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Warsaw
|
||||||
|
networks:
|
||||||
|
- gpon-monitor-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:${LISTEN_PORT:-8080}/api/current"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
gpon-monitor-net:
|
||||||
|
driver: bridge
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Flask==3.0.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
telnetlib3==2.0.4
|
||||||
|
aiohttp
|
||||||
365
rrd_manager.py
Normal file
365
rrd_manager.py
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class RRDManager:
|
||||||
|
def __init__(self, rrd_dir):
|
||||||
|
self.rrd_dir = Path(rrd_dir)
|
||||||
|
self.rrd_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['rrdtool', '--version'],
|
||||||
|
capture_output=True, check=True, text=True)
|
||||||
|
version = result.stdout.split('\n')[0]
|
||||||
|
logger.info(f"RRDtool available: {version}")
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||||
|
logger.error("RRDtool not installed!")
|
||||||
|
raise RuntimeError("Install rrdtool: sudo apt-get install rrdtool")
|
||||||
|
|
||||||
|
self.rrd_files = {
|
||||||
|
'optical': self.rrd_dir / 'optical.rrd',
|
||||||
|
'traffic': self.rrd_dir / 'traffic.rrd',
|
||||||
|
'fec': self.rrd_dir / 'fec.rrd',
|
||||||
|
'system': self.rrd_dir / 'system.rrd',
|
||||||
|
}
|
||||||
|
|
||||||
|
self._init_rrds()
|
||||||
|
|
||||||
|
def _init_rrds(self):
|
||||||
|
if not self.rrd_files['optical'].exists():
|
||||||
|
logger.info(f"Creating {self.rrd_files['optical']}")
|
||||||
|
cmd = [
|
||||||
|
'rrdtool', 'create', str(self.rrd_files['optical']),
|
||||||
|
'--step', '60',
|
||||||
|
'--start', 'now-10s',
|
||||||
|
'DS:rx_power:GAUGE:300:-40:10',
|
||||||
|
'DS:tx_power:GAUGE:300:-10:10',
|
||||||
|
'DS:voltage:GAUGE:300:0:5',
|
||||||
|
'DS:tx_bias:GAUGE:300:0:200',
|
||||||
|
'DS:temperature:GAUGE:300:-20:100',
|
||||||
|
'RRA:LAST:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:5:2016',
|
||||||
|
'RRA:AVERAGE:0.5:30:1488',
|
||||||
|
'RRA:AVERAGE:0.5:360:730',
|
||||||
|
'RRA:MAX:0.5:1:1440',
|
||||||
|
'RRA:MAX:0.5:5:2016',
|
||||||
|
'RRA:MIN:0.5:1:1440',
|
||||||
|
'RRA:MIN:0.5:5:2016',
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
logger.info("optical.rrd created")
|
||||||
|
else:
|
||||||
|
logger.info(f"optical.rrd exists: {self.rrd_files['optical']}")
|
||||||
|
|
||||||
|
if not self.rrd_files['traffic'].exists():
|
||||||
|
logger.info(f"Creating {self.rrd_files['traffic']}")
|
||||||
|
cmd = [
|
||||||
|
'rrdtool', 'create', str(self.rrd_files['traffic']),
|
||||||
|
'--step', '60',
|
||||||
|
'--start', 'now-10s',
|
||||||
|
'DS:rx_packets:DERIVE:300:0:U',
|
||||||
|
'DS:tx_packets:DERIVE:300:0:U',
|
||||||
|
'DS:rx_bytes:DERIVE:300:0:U',
|
||||||
|
'DS:tx_bytes:DERIVE:300:0:U',
|
||||||
|
'RRA:LAST:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:5:2016',
|
||||||
|
'RRA:AVERAGE:0.5:30:1488',
|
||||||
|
'RRA:AVERAGE:0.5:360:730',
|
||||||
|
'RRA:MAX:0.5:1:1440',
|
||||||
|
'RRA:MAX:0.5:5:2016',
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
logger.info("traffic.rrd created")
|
||||||
|
else:
|
||||||
|
logger.info(f"traffic.rrd exists: {self.rrd_files['traffic']}")
|
||||||
|
|
||||||
|
if not self.rrd_files['fec'].exists():
|
||||||
|
logger.info(f"Creating {self.rrd_files['fec']}")
|
||||||
|
cmd = [
|
||||||
|
'rrdtool', 'create', str(self.rrd_files['fec']),
|
||||||
|
'--step', '60',
|
||||||
|
'--start', 'now-10s',
|
||||||
|
'DS:corrected:DERIVE:300:0:U',
|
||||||
|
'DS:uncorrected:DERIVE:300:0:U',
|
||||||
|
'DS:total_codewords:DERIVE:300:0:U',
|
||||||
|
'RRA:LAST:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:5:2016',
|
||||||
|
'RRA:AVERAGE:0.5:30:1488',
|
||||||
|
'RRA:MAX:0.5:1:1440',
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
logger.info("fec.rrd created")
|
||||||
|
else:
|
||||||
|
logger.info(f"fec.rrd exists: {self.rrd_files['fec']}")
|
||||||
|
|
||||||
|
if not self.rrd_files['system'].exists():
|
||||||
|
logger.info(f"Creating {self.rrd_files['system']}")
|
||||||
|
cmd = [
|
||||||
|
'rrdtool', 'create', str(self.rrd_files['system']),
|
||||||
|
'--step', '60',
|
||||||
|
'--start', 'now-10s',
|
||||||
|
'DS:uptime:GAUGE:300:0:U',
|
||||||
|
'DS:status:GAUGE:300:0:1',
|
||||||
|
'RRA:LAST:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:1:1440',
|
||||||
|
'RRA:AVERAGE:0.5:5:2016',
|
||||||
|
'RRA:AVERAGE:0.5:30:1488',
|
||||||
|
]
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
logger.info("system.rrd created")
|
||||||
|
else:
|
||||||
|
logger.info(f"system.rrd exists: {self.rrd_files['system']}")
|
||||||
|
|
||||||
|
def update(self, data):
|
||||||
|
timestamp = 'N'
|
||||||
|
updates_ok = 0
|
||||||
|
updates_fail = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
if data.get('rx_power') is not None and data.get('tx_power') is not None:
|
||||||
|
values = ':'.join([
|
||||||
|
timestamp,
|
||||||
|
str(data.get('rx_power', 'U')),
|
||||||
|
str(data.get('tx_power', 'U')),
|
||||||
|
str(data.get('voltage', 'U')),
|
||||||
|
str(data.get('tx_bias_current', 'U')),
|
||||||
|
str(data.get('temperature', 'U'))
|
||||||
|
])
|
||||||
|
try:
|
||||||
|
subprocess.run(['rrdtool', 'update', str(self.rrd_files['optical']), values],
|
||||||
|
check=True, capture_output=True, text=True)
|
||||||
|
logger.info(f"RRD optical: RX={data.get('rx_power'):.2f} TX={data.get('tx_power'):.2f} Temp={data.get('temperature', 'N/A')}")
|
||||||
|
updates_ok += 1
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"RRD optical failed: {e.stderr}")
|
||||||
|
updates_fail += 1
|
||||||
|
else:
|
||||||
|
logger.warning("RRD optical: missing RX/TX power data")
|
||||||
|
|
||||||
|
if data.get('rx_packets') is not None:
|
||||||
|
values = ':'.join([
|
||||||
|
timestamp,
|
||||||
|
str(data.get('rx_packets', 0)),
|
||||||
|
str(data.get('tx_packets', 0)),
|
||||||
|
str(data.get('rx_bytes', 0)),
|
||||||
|
str(data.get('tx_bytes', 0))
|
||||||
|
])
|
||||||
|
try:
|
||||||
|
subprocess.run(['rrdtool', 'update', str(self.rrd_files['traffic']), values],
|
||||||
|
check=True, capture_output=True, text=True)
|
||||||
|
logger.info(f"RRD traffic: RX={data.get('rx_packets'):,} TX={data.get('tx_packets'):,} pkts")
|
||||||
|
updates_ok += 1
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"RRD traffic failed: {e.stderr}")
|
||||||
|
updates_fail += 1
|
||||||
|
else:
|
||||||
|
logger.warning("RRD traffic: missing packets data")
|
||||||
|
|
||||||
|
if data.get('fec_corrected') is not None:
|
||||||
|
values = ':'.join([
|
||||||
|
timestamp,
|
||||||
|
str(data.get('fec_corrected', 0)),
|
||||||
|
str(data.get('fec_uncorrected', 0)),
|
||||||
|
str(data.get('fec_total_codewords', 0))
|
||||||
|
])
|
||||||
|
try:
|
||||||
|
subprocess.run(['rrdtool', 'update', str(self.rrd_files['fec']), values],
|
||||||
|
check=True, capture_output=True, text=True)
|
||||||
|
logger.info(f"RRD fec: corrected={data.get('fec_corrected')} uncorrected={data.get('fec_uncorrected')}")
|
||||||
|
updates_ok += 1
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"RRD fec failed: {e.stderr}")
|
||||||
|
updates_fail += 1
|
||||||
|
else:
|
||||||
|
logger.warning("RRD fec: missing FEC data")
|
||||||
|
|
||||||
|
status_val = 1 if data.get('status') == 'online' else 0
|
||||||
|
values = f"{timestamp}:{data.get('uptime', 0)}:{status_val}"
|
||||||
|
try:
|
||||||
|
subprocess.run(['rrdtool', 'update', str(self.rrd_files['system']), values],
|
||||||
|
check=True, capture_output=True, text=True)
|
||||||
|
logger.info(f"RRD system: uptime={data.get('uptime', 0)}s status={data.get('status')}")
|
||||||
|
updates_ok += 1
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"RRD system failed: {e.stderr}")
|
||||||
|
updates_fail += 1
|
||||||
|
|
||||||
|
logger.info(f"RRD update summary: {updates_ok} OK, {updates_fail} failed")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"RRD update error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def fetch(self, metric, period='6h', cf=None):
|
||||||
|
periods = {
|
||||||
|
'1h': 3600,
|
||||||
|
'6h': 21600,
|
||||||
|
'12h': 43200,
|
||||||
|
'24h': 86400,
|
||||||
|
'3d': 259200,
|
||||||
|
'7d': 604800,
|
||||||
|
'14d': 1209600,
|
||||||
|
'30d': 2592000,
|
||||||
|
'60d': 5184000,
|
||||||
|
'90d': 7776000,
|
||||||
|
'120d': 10368000,
|
||||||
|
'1y': 31536000,
|
||||||
|
'2y': 63072000,
|
||||||
|
'5y': 157680000,
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds = periods.get(period, 21600)
|
||||||
|
start = f"-{seconds}s"
|
||||||
|
|
||||||
|
rrd_file = self.rrd_files.get(metric)
|
||||||
|
if not rrd_file or not rrd_file.exists():
|
||||||
|
logger.warning(f"RRD file not found: {metric} -> {rrd_file}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if cf is None:
|
||||||
|
if metric in ['traffic', 'fec']:
|
||||||
|
cf = 'AVERAGE'
|
||||||
|
else:
|
||||||
|
cf = 'AVERAGE'
|
||||||
|
|
||||||
|
resolution = None
|
||||||
|
if seconds <= 3600:
|
||||||
|
resolution = 60
|
||||||
|
elif seconds <= 43200:
|
||||||
|
resolution = 300
|
||||||
|
elif seconds <= 604800:
|
||||||
|
resolution = 300
|
||||||
|
elif seconds <= 2592000:
|
||||||
|
resolution = 1800
|
||||||
|
else:
|
||||||
|
resolution = 21600
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
'rrdtool', 'fetch',
|
||||||
|
str(rrd_file),
|
||||||
|
cf,
|
||||||
|
'--start', start,
|
||||||
|
'--end', 'now',
|
||||||
|
'--resolution', str(resolution)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
parsed = self._parse_fetch_output(result.stdout)
|
||||||
|
|
||||||
|
if parsed:
|
||||||
|
data_points = len(parsed['timestamps'])
|
||||||
|
first_ds = parsed['ds_names'][0] if parsed['ds_names'] else None
|
||||||
|
non_null_count = sum(1 for v in parsed['data'].get(first_ds, []) if v is not None) if first_ds else 0
|
||||||
|
|
||||||
|
max_expected_points = int(seconds / resolution)
|
||||||
|
if data_points > max_expected_points * 2:
|
||||||
|
logger.warning(f"RRD returned too many points: {data_points} (expected ~{max_expected_points}), applying downsampling")
|
||||||
|
parsed = self._downsample_data(parsed, max_points=800)
|
||||||
|
data_points = len(parsed['timestamps'])
|
||||||
|
|
||||||
|
logger.info(f"RRD fetch {metric}/{period}/{cf} resolution={resolution}s: {data_points} points, {non_null_count} non-null")
|
||||||
|
else:
|
||||||
|
logger.warning(f"RRD fetch {metric}/{period}: no data parsed")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"RRD fetch error {metric}: {e.stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _downsample_data(self, parsed, max_points=800):
|
||||||
|
if len(parsed['timestamps']) <= max_points:
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
step = len(parsed['timestamps']) // max_points
|
||||||
|
|
||||||
|
new_timestamps = parsed['timestamps'][::step]
|
||||||
|
new_data = {}
|
||||||
|
|
||||||
|
for ds_name in parsed['ds_names']:
|
||||||
|
original = parsed['data'][ds_name]
|
||||||
|
downsampled = []
|
||||||
|
|
||||||
|
for i in range(0, len(original), step):
|
||||||
|
chunk = original[i:i+step]
|
||||||
|
valid = [v for v in chunk if v is not None]
|
||||||
|
if valid:
|
||||||
|
downsampled.append(sum(valid) / len(valid))
|
||||||
|
else:
|
||||||
|
downsampled.append(None)
|
||||||
|
|
||||||
|
new_data[ds_name] = downsampled
|
||||||
|
|
||||||
|
logger.info(f"Backend downsampled: {len(parsed['timestamps'])} -> {len(new_timestamps)} points")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'start': parsed['start'],
|
||||||
|
'end': parsed['end'],
|
||||||
|
'step': parsed['step'] * step,
|
||||||
|
'ds_names': parsed['ds_names'],
|
||||||
|
'timestamps': new_timestamps,
|
||||||
|
'data': new_data
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_fetch_output(self, output):
|
||||||
|
lines = output.strip().split('\n')
|
||||||
|
if len(lines) < 2:
|
||||||
|
logger.warning("RRD fetch output too short")
|
||||||
|
return None
|
||||||
|
|
||||||
|
ds_names = lines[0].split()
|
||||||
|
logger.debug(f"RRD DS names: {ds_names}")
|
||||||
|
|
||||||
|
timestamps = []
|
||||||
|
data = {name: [] for name in ds_names}
|
||||||
|
|
||||||
|
for line in lines[1:]:
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split(':')
|
||||||
|
try:
|
||||||
|
timestamp = int(parts[0].strip())
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
values = parts[1].strip().split()
|
||||||
|
timestamps.append(timestamp)
|
||||||
|
|
||||||
|
for i, value_str in enumerate(values):
|
||||||
|
if i < len(ds_names):
|
||||||
|
try:
|
||||||
|
if 'nan' in value_str.lower():
|
||||||
|
value = None
|
||||||
|
else:
|
||||||
|
value_str = value_str.replace(',', '.')
|
||||||
|
value = float(value_str)
|
||||||
|
except ValueError:
|
||||||
|
value = None
|
||||||
|
|
||||||
|
data[ds_names[i]].append(value)
|
||||||
|
|
||||||
|
if not timestamps:
|
||||||
|
logger.warning("No timestamps found in RRD output")
|
||||||
|
return None
|
||||||
|
|
||||||
|
step = (timestamps[1] - timestamps[0]) if len(timestamps) > 1 else 60
|
||||||
|
|
||||||
|
for ds_name in ds_names:
|
||||||
|
non_null = sum(1 for v in data[ds_name] if v is not None)
|
||||||
|
logger.debug(f"RRD {ds_name}: {non_null}/{len(data[ds_name])} non-null values")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'start': timestamps[0],
|
||||||
|
'end': timestamps[-1],
|
||||||
|
'step': step,
|
||||||
|
'ds_names': ds_names,
|
||||||
|
'timestamps': timestamps,
|
||||||
|
'data': data
|
||||||
|
}
|
||||||
102
static/css/style.css
Normal file
102
static/css/style.css
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #1e1e1e;
|
||||||
|
--bg-secondary: #2d2d2d;
|
||||||
|
--bg-card: #252525;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #b0b0b0;
|
||||||
|
--accent-info: #00ccff;
|
||||||
|
--accent-warning: #ffcc00;
|
||||||
|
--accent-danger: #ff3366;
|
||||||
|
--accent-success: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 255, 136, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-unit {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-critical {
|
||||||
|
border-left: 4px solid var(--accent-danger);
|
||||||
|
background-color: rgba(255, 51, 102, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
border-left: 4px solid var(--accent-warning);
|
||||||
|
background-color: rgba(255, 204, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online {
|
||||||
|
color: var(--accent-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
color: var(--accent-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background-color: var(--bg-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.metric-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
692
static/js/charts.js
Normal file
692
static/js/charts.js
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
class GPONCharts {
|
||||||
|
constructor() {
|
||||||
|
this.charts = {};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createOpticalChart();
|
||||||
|
this.createTemperatureChart();
|
||||||
|
this.createTrafficChart();
|
||||||
|
this.createBytesChart();
|
||||||
|
this.createVolumeChart();
|
||||||
|
this.createFECChart();
|
||||||
|
|
||||||
|
this.setupGlobalPeriodSelector();
|
||||||
|
|
||||||
|
document.getElementById('period-optical').addEventListener('change', (e) => {
|
||||||
|
this.updateOpticalChart(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('period-temperature').addEventListener('change', (e) => {
|
||||||
|
this.updateTemperatureChart(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('period-traffic').addEventListener('change', (e) => {
|
||||||
|
this.updateTrafficChart(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('period-bytes').addEventListener('change', (e) => {
|
||||||
|
this.updateBytesChart(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('period-volume').addEventListener('change', (e) => {
|
||||||
|
this.updateVolumeChart(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('period-fec').addEventListener('change', (e) => {
|
||||||
|
this.updateFECChart(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="volume-chart-type"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', (e) => {
|
||||||
|
this.changeVolumeChartType(e.target.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateOpticalChart('1h');
|
||||||
|
this.updateTemperatureChart('1h');
|
||||||
|
this.updateTrafficChart('1h');
|
||||||
|
this.updateBytesChart('1h');
|
||||||
|
this.updateVolumeChart('1h');
|
||||||
|
this.updateFECChart('1h');
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const optPeriod = document.getElementById('period-optical').value;
|
||||||
|
const tempPeriod = document.getElementById('period-temperature').value;
|
||||||
|
const trafficPeriod = document.getElementById('period-traffic').value;
|
||||||
|
const bytesPeriod = document.getElementById('period-bytes').value;
|
||||||
|
const volumePeriod = document.getElementById('period-volume').value;
|
||||||
|
const fecPeriod = document.getElementById('period-fec').value;
|
||||||
|
|
||||||
|
this.updateOpticalChart(optPeriod);
|
||||||
|
this.updateTemperatureChart(tempPeriod);
|
||||||
|
this.updateTrafficChart(trafficPeriod);
|
||||||
|
this.updateBytesChart(bytesPeriod);
|
||||||
|
this.updateVolumeChart(volumePeriod);
|
||||||
|
this.updateFECChart(fecPeriod);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
console.log('[Charts] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGlobalPeriodSelector() {
|
||||||
|
const radios = document.querySelectorAll('input[name="global-period"]');
|
||||||
|
|
||||||
|
radios.forEach(radio => {
|
||||||
|
radio.addEventListener('change', (e) => {
|
||||||
|
const period = e.target.value;
|
||||||
|
|
||||||
|
document.getElementById('period-optical').value = period;
|
||||||
|
document.getElementById('period-temperature').value = period;
|
||||||
|
document.getElementById('period-traffic').value = period;
|
||||||
|
document.getElementById('period-bytes').value = period;
|
||||||
|
document.getElementById('period-volume').value = period;
|
||||||
|
document.getElementById('period-fec').value = period;
|
||||||
|
|
||||||
|
this.updateOpticalChart(period);
|
||||||
|
this.updateTemperatureChart(period);
|
||||||
|
this.updateTrafficChart(period);
|
||||||
|
this.updateBytesChart(period);
|
||||||
|
this.updateVolumeChart(period);
|
||||||
|
this.updateFECChart(period);
|
||||||
|
|
||||||
|
console.log('[Charts] Global period: ' + period);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createOpticalChart() {
|
||||||
|
const ctx = document.getElementById('chart-optical').getContext('2d');
|
||||||
|
|
||||||
|
this.charts.optical = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'RX Power (dBm)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#00ccff',
|
||||||
|
backgroundColor: 'rgba(0, 204, 255, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TX Power (dBm)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ffcc00',
|
||||||
|
backgroundColor: 'rgba(255, 204, 0, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { color: '#ffffff' } },
|
||||||
|
tooltip: { mode: 'index', intersect: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#b0b0b0', maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: '#b0b0b0' },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
title: { display: true, text: 'dBm', color: '#b0b0b0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createTemperatureChart() {
|
||||||
|
const ctx = document.getElementById('chart-temperature').getContext('2d');
|
||||||
|
|
||||||
|
this.charts.temperature = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Temperature (°C)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff6b6b',
|
||||||
|
backgroundColor: 'rgba(255, 107, 107, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { color: '#ffffff' } },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return context.parsed.y ? context.parsed.y.toFixed(1) + '°C' : 'N/A';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#b0b0b0', maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: '#b0b0b0' },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
title: { display: true, text: '°C', color: '#b0b0b0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createTrafficChart() {
|
||||||
|
const ctx = document.getElementById('chart-traffic').getContext('2d');
|
||||||
|
|
||||||
|
this.charts.traffic = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'RX Packets/s',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#4bc0c0',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TX Packets/s',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff6384',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { color: '#ffffff' } },
|
||||||
|
tooltip: { mode: 'index', intersect: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#b0b0b0', maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: '#b0b0b0' },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
title: { display: true, text: 'packets/s', color: '#b0b0b0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createBytesChart() {
|
||||||
|
const ctx = document.getElementById('chart-bytes').getContext('2d');
|
||||||
|
|
||||||
|
this.charts.bytes = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'RX (Mb/s)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#4bc0c0',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TX (Mb/s)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff6384',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { color: '#ffffff' } },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return context.dataset.label + ': ' +
|
||||||
|
(context.parsed.y ? context.parsed.y.toFixed(2) : '0') + ' Mb/s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#b0b0b0', maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: '#b0b0b0',
|
||||||
|
callback: function(value) {
|
||||||
|
return value.toFixed(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
title: { display: true, text: 'Mb/s', color: '#b0b0b0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createVolumeChart() {
|
||||||
|
const ctx = document.getElementById('chart-volume').getContext('2d');
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
this.charts.volume = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'RX per minute',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#4bc0c0',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.05)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TX per minute',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff6384',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.05)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { color: '#ffffff' } },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const bytes = context.parsed.y;
|
||||||
|
const formatted = self.formatBytes(bytes);
|
||||||
|
return context.dataset.label + ': ' + formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: false,
|
||||||
|
ticks: { color: '#b0b0b0', maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: false,
|
||||||
|
ticks: {
|
||||||
|
color: '#b0b0b0',
|
||||||
|
callback: function(value) {
|
||||||
|
return self.formatBytes(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
title: { display: true, text: 'Data per minute', color: '#b0b0b0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createFECChart() {
|
||||||
|
const ctx = document.getElementById('chart-fec').getContext('2d');
|
||||||
|
|
||||||
|
this.charts.fec = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'FEC Corrected',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ffa500',
|
||||||
|
backgroundColor: 'rgba(255, 165, 0, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'FEC Uncorrected',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ff0000',
|
||||||
|
backgroundColor: 'rgba(255, 0, 0, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { color: '#ffffff' } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#b0b0b0', maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: '#b0b0b0' },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
title: { display: true, text: 'errors/s', color: '#b0b0b0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
changeVolumeChartType(type) {
|
||||||
|
if (!this.charts.volume) return;
|
||||||
|
|
||||||
|
console.log('[Charts] Changing volume chart to:', type);
|
||||||
|
|
||||||
|
const currentData = {
|
||||||
|
labels: this.charts.volume.data.labels,
|
||||||
|
datasets: this.charts.volume.data.datasets
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.volume.destroy();
|
||||||
|
const ctx = document.getElementById('chart-volume').getContext('2d');
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
type: type,
|
||||||
|
data: {
|
||||||
|
labels: currentData.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'RX per minute',
|
||||||
|
data: currentData.datasets[0].data,
|
||||||
|
borderColor: '#4bc0c0',
|
||||||
|
backgroundColor: type === 'bar' ? 'rgba(75, 192, 192, 0.6)' : 'rgba(75, 192, 192, 0.05)',
|
||||||
|
borderWidth: type === 'bar' ? 1 : 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TX per minute',
|
||||||
|
data: currentData.datasets[1].data,
|
||||||
|
borderColor: '#ff6384',
|
||||||
|
backgroundColor: type === 'bar' ? 'rgba(255, 99, 132, 0.6)' : 'rgba(255, 99, 132, 0.05)',
|
||||||
|
borderWidth: type === 'bar' ? 1 : 2,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, labels: { color: '#ffffff' } },
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
const bytes = context.parsed.y;
|
||||||
|
const formatted = self.formatBytes(bytes);
|
||||||
|
return context.dataset.label + ': ' + formatted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: false,
|
||||||
|
ticks: { color: '#b0b0b0', maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: false,
|
||||||
|
ticks: {
|
||||||
|
color: '#b0b0b0',
|
||||||
|
callback: function(value) {
|
||||||
|
return self.formatBytes(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.1)' },
|
||||||
|
title: { display: true, text: 'Data per minute', color: '#b0b0b0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts.volume = new Chart(ctx, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpticalChart(period) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history/optical/' + period);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.timestamps || data.timestamps.length === 0) {
|
||||||
|
console.warn('[Charts] No optical data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = this.formatLabels(data.timestamps, period);
|
||||||
|
|
||||||
|
this.charts.optical.data.labels = labels;
|
||||||
|
this.charts.optical.data.datasets[0].data = data.data.rx_power || [];
|
||||||
|
this.charts.optical.data.datasets[1].data = data.data.tx_power || [];
|
||||||
|
this.charts.optical.update();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Charts] Error optical:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemperatureChart(period) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history/optical/' + period);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.timestamps || data.timestamps.length === 0) return;
|
||||||
|
|
||||||
|
const labels = this.formatLabels(data.timestamps, period);
|
||||||
|
|
||||||
|
this.charts.temperature.data.labels = labels;
|
||||||
|
this.charts.temperature.data.datasets[0].data = data.data.temperature || [];
|
||||||
|
this.charts.temperature.update();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Charts] Error temperature:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTrafficChart(period) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history/traffic/' + period);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.timestamps || data.timestamps.length === 0) return;
|
||||||
|
|
||||||
|
const labels = this.formatLabels(data.timestamps, period);
|
||||||
|
|
||||||
|
const rxPps = data.data.rx_packets || [];
|
||||||
|
const txPps = data.data.tx_packets || [];
|
||||||
|
|
||||||
|
this.charts.traffic.data.labels = labels;
|
||||||
|
this.charts.traffic.data.datasets[0].data = rxPps;
|
||||||
|
this.charts.traffic.data.datasets[1].data = txPps;
|
||||||
|
this.charts.traffic.update();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Charts] Error traffic:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBytesChart(period) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history/traffic/' + period);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.timestamps || data.timestamps.length === 0) return;
|
||||||
|
|
||||||
|
const labels = this.formatLabels(data.timestamps, period);
|
||||||
|
|
||||||
|
const rxMbps = (data.data.rx_bytes || []).map(v =>
|
||||||
|
v !== null ? (v * 8) / 1000000 : null
|
||||||
|
);
|
||||||
|
const txMbps = (data.data.tx_bytes || []).map(v =>
|
||||||
|
v !== null ? (v * 8) / 1000000 : null
|
||||||
|
);
|
||||||
|
|
||||||
|
this.charts.bytes.data.labels = labels;
|
||||||
|
this.charts.bytes.data.datasets[0].data = rxMbps;
|
||||||
|
this.charts.bytes.data.datasets[1].data = txMbps;
|
||||||
|
this.charts.bytes.update();
|
||||||
|
|
||||||
|
console.log('[Charts] Bytes - max RX Mb/s:', Math.max(...rxMbps.filter(v => v !== null)).toFixed(2));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Charts] Error bytes:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVolumeChart(period) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history/traffic/' + period);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.timestamps || data.timestamps.length === 0) return;
|
||||||
|
|
||||||
|
const labels = this.formatLabels(data.timestamps, period);
|
||||||
|
|
||||||
|
const rxBytesRate = data.data.rx_bytes || [];
|
||||||
|
const txBytesRate = data.data.tx_bytes || [];
|
||||||
|
const step = data.step || 60;
|
||||||
|
|
||||||
|
const rxPerInterval = rxBytesRate.map(rate =>
|
||||||
|
rate !== null ? rate * step : null
|
||||||
|
);
|
||||||
|
const txPerInterval = txBytesRate.map(rate =>
|
||||||
|
rate !== null ? rate * step : null
|
||||||
|
);
|
||||||
|
|
||||||
|
this.charts.volume.data.labels = labels;
|
||||||
|
this.charts.volume.data.datasets[0].data = rxPerInterval;
|
||||||
|
this.charts.volume.data.datasets[1].data = txPerInterval;
|
||||||
|
this.charts.volume.update();
|
||||||
|
|
||||||
|
const maxRx = Math.max(...rxPerInterval.filter(v => v !== null));
|
||||||
|
console.log('[Charts] Volume - max per minute:', this.formatBytes(maxRx));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Charts] Error volume:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFECChart(period) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/history/fec/' + period);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.timestamps || data.timestamps.length === 0) return;
|
||||||
|
|
||||||
|
const labels = this.formatLabels(data.timestamps, period);
|
||||||
|
|
||||||
|
const correctedRate = data.data.corrected || [];
|
||||||
|
const uncorrectedRate = data.data.uncorrected || [];
|
||||||
|
|
||||||
|
this.charts.fec.data.labels = labels;
|
||||||
|
this.charts.fec.data.datasets[0].data = correctedRate;
|
||||||
|
this.charts.fec.data.datasets[1].data = uncorrectedRate;
|
||||||
|
this.charts.fec.update();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Charts] Error FEC:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatLabels(timestamps, period) {
|
||||||
|
return timestamps.map(ts => {
|
||||||
|
const date = new Date(ts * 1000);
|
||||||
|
|
||||||
|
if (period === '7d' || period === '14d' || period === '30d' || period === '60d' || period === '90d') {
|
||||||
|
return date.toLocaleDateString('pl-PL', { day: '2-digit', month: '2-digit' }) + ' ' +
|
||||||
|
date.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes, decimals = 2) {
|
||||||
|
if (bytes === null || bytes === undefined) return 'N/A';
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.charts = new GPONCharts();
|
||||||
|
});
|
||||||
256
static/js/dashboard.js
Normal file
256
static/js/dashboard.js
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
class GPONDashboard {
|
||||||
|
constructor() {
|
||||||
|
this.updateInterval = 5000;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.updateCurrent();
|
||||||
|
this.updateAlerts();
|
||||||
|
|
||||||
|
setInterval(() => this.updateCurrent(), this.updateInterval);
|
||||||
|
setInterval(() => this.updateAlerts(), 10000);
|
||||||
|
|
||||||
|
console.log('[Dashboard] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCurrent() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/current');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log('[Dashboard] Data received:', data);
|
||||||
|
|
||||||
|
this.updateMetrics(data);
|
||||||
|
this.updateDeviceInfo(data);
|
||||||
|
this.updateStatus(data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Dashboard] Error:', error);
|
||||||
|
this.updateStatus({ status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMetrics(data) {
|
||||||
|
if (data.rx_power !== undefined && data.rx_power !== null) {
|
||||||
|
document.getElementById('rx-power').textContent = data.rx_power.toFixed(2);
|
||||||
|
const rxPercent = ((data.rx_power + 30) / 22) * 100;
|
||||||
|
const bar = document.getElementById('rx-power-bar');
|
||||||
|
bar.style.width = Math.max(0, Math.min(100, rxPercent)) + '%';
|
||||||
|
|
||||||
|
if (data.rx_power < -28) {
|
||||||
|
bar.className = 'progress-bar bg-danger';
|
||||||
|
} else if (data.rx_power < -25) {
|
||||||
|
bar.className = 'progress-bar bg-warning';
|
||||||
|
} else {
|
||||||
|
bar.className = 'progress-bar bg-info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.tx_power !== undefined && data.tx_power !== null) {
|
||||||
|
document.getElementById('tx-power').textContent = data.tx_power.toFixed(2);
|
||||||
|
const txPercent = ((data.tx_power + 5) / 10) * 100;
|
||||||
|
document.getElementById('tx-power-bar').style.width = Math.max(0, Math.min(100, txPercent)) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.temperature !== undefined && data.temperature !== null) {
|
||||||
|
document.getElementById('temperature').textContent = data.temperature.toFixed(1);
|
||||||
|
const tempPercent = (data.temperature / 100) * 100;
|
||||||
|
const bar = document.getElementById('temp-bar');
|
||||||
|
bar.style.width = Math.max(0, Math.min(100, tempPercent)) + '%';
|
||||||
|
|
||||||
|
if (data.temperature > 80) {
|
||||||
|
bar.className = 'progress-bar bg-danger';
|
||||||
|
} else if (data.temperature > 60) {
|
||||||
|
bar.className = 'progress-bar bg-warning';
|
||||||
|
} else {
|
||||||
|
bar.className = 'progress-bar bg-success';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uptimeElem = document.getElementById('uptime');
|
||||||
|
if (uptimeElem) {
|
||||||
|
if (data.uptime && data.uptime > 0) {
|
||||||
|
const seconds = parseInt(data.uptime);
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
let uptimeStr = '';
|
||||||
|
if (days > 0) uptimeStr += days + 'd ';
|
||||||
|
if (hours > 0 || days > 0) uptimeStr += hours + 'h ';
|
||||||
|
uptimeStr += minutes + 'm';
|
||||||
|
|
||||||
|
uptimeElem.textContent = uptimeStr.trim();
|
||||||
|
} else {
|
||||||
|
uptimeElem.textContent = '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDeviceInfo(data) {
|
||||||
|
this.setElementText('vendor-id', data.vendor_id);
|
||||||
|
this.setElementText('serial-number', data.serial_number);
|
||||||
|
this.setElementText('version', data.version);
|
||||||
|
this.setElementText('mac-address', data.mac_address);
|
||||||
|
|
||||||
|
this.setElementText('olt-vendor-info', data.olt_vendor_info);
|
||||||
|
this.setElementText('olt-version-info', data.olt_version_info);
|
||||||
|
|
||||||
|
const connTimeElem = document.getElementById('connection-time');
|
||||||
|
if (connTimeElem) {
|
||||||
|
if (data.connection_time) {
|
||||||
|
connTimeElem.textContent = data.connection_time;
|
||||||
|
} else if (data.uptime && data.uptime > 0) {
|
||||||
|
const seconds = parseInt(data.uptime);
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
let connTime = '';
|
||||||
|
if (days > 0) connTime += days + 'd ';
|
||||||
|
if (hours > 0 || days > 0) connTime += hours + 'h ';
|
||||||
|
connTime += minutes + 'm';
|
||||||
|
|
||||||
|
connTimeElem.textContent = connTime.trim();
|
||||||
|
} else {
|
||||||
|
connTimeElem.textContent = '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setElementText('rx-packets', (data.rx_packets || 0).toLocaleString('en-US'));
|
||||||
|
this.setElementText('tx-packets', (data.tx_packets || 0).toLocaleString('en-US'));
|
||||||
|
this.setElementText('fec-corrected', (data.fec_corrected || 0).toLocaleString('en-US'));
|
||||||
|
this.setElementText('fec-uncorrected', (data.fec_uncorrected || 0).toLocaleString('en-US'));
|
||||||
|
|
||||||
|
if (data.voltage !== undefined && data.voltage !== null) {
|
||||||
|
this.setElementText('voltage', data.voltage.toFixed(2) + ' V');
|
||||||
|
} else {
|
||||||
|
this.setElementText('voltage', 'N/A');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.tx_bias_current !== undefined && data.tx_bias_current !== null) {
|
||||||
|
this.setElementText('tx-bias', data.tx_bias_current.toFixed(2) + ' mA');
|
||||||
|
} else {
|
||||||
|
this.setElementText('tx-bias', 'N/A');
|
||||||
|
}
|
||||||
|
|
||||||
|
const volumeElem = document.getElementById('data-volume-total');
|
||||||
|
if (volumeElem) {
|
||||||
|
if (data.rx_bytes && data.tx_bytes) {
|
||||||
|
const totalBytes = parseInt(data.rx_bytes) + parseInt(data.tx_bytes);
|
||||||
|
volumeElem.textContent = this.formatBytes(totalBytes);
|
||||||
|
} else {
|
||||||
|
volumeElem.textContent = '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusElem = document.getElementById('device-status');
|
||||||
|
if (statusElem) {
|
||||||
|
if (data.status === 'online') {
|
||||||
|
statusElem.innerHTML = '<span class="badge bg-success">Online</span>';
|
||||||
|
} else {
|
||||||
|
statusElem.innerHTML = '<span class="badge bg-danger">Offline</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastUpdateElem = document.getElementById('last-update');
|
||||||
|
if (lastUpdateElem) {
|
||||||
|
if (data.timestamp) {
|
||||||
|
try {
|
||||||
|
const date = new Date(data.timestamp);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
lastUpdateElem.textContent = date.toLocaleTimeString('en-US');
|
||||||
|
} else {
|
||||||
|
lastUpdateElem.textContent = '--';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Dashboard] Timestamp error:', e);
|
||||||
|
lastUpdateElem.textContent = '--';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastUpdateElem.textContent = '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(data) {
|
||||||
|
const indicator = document.getElementById('status-indicator');
|
||||||
|
if (!indicator) return;
|
||||||
|
|
||||||
|
if (data.status === 'online') {
|
||||||
|
indicator.innerHTML = '<i class="bi bi-circle-fill text-success"></i> Online';
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
indicator.innerHTML = '<i class="bi bi-circle-fill text-danger"></i> Error';
|
||||||
|
} else {
|
||||||
|
indicator.innerHTML = '<i class="bi bi-circle-fill text-warning"></i> Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAlerts() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/alerts');
|
||||||
|
const alerts = await response.json();
|
||||||
|
|
||||||
|
const container = document.getElementById('alerts-container');
|
||||||
|
if (!container || !alerts || alerts.length === 0) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
const alertDiv = document.createElement('div');
|
||||||
|
|
||||||
|
let alertClass = 'alert-warning';
|
||||||
|
if (alert.severity === 'critical') alertClass = 'alert-danger';
|
||||||
|
else if (alert.severity === 'info') alertClass = 'alert-info';
|
||||||
|
|
||||||
|
alertDiv.className = `alert ${alertClass} alert-dismissible fade show`;
|
||||||
|
|
||||||
|
const icon = alert.severity === 'critical' ? 'bi-exclamation-octagon' :
|
||||||
|
(alert.severity === 'info' ? 'bi-info-circle' : 'bi-exclamation-triangle');
|
||||||
|
|
||||||
|
let timestamp = '';
|
||||||
|
if (alert.timestamp) {
|
||||||
|
try {
|
||||||
|
const date = new Date(alert.timestamp);
|
||||||
|
timestamp = date.toLocaleTimeString('en-US');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = (alert.category || 'system').toUpperCase();
|
||||||
|
|
||||||
|
alertDiv.innerHTML = `
|
||||||
|
<i class="bi ${icon}"></i>
|
||||||
|
<strong>${category}:</strong> ${alert.message}
|
||||||
|
${timestamp ? `<small class="float-end">${timestamp}</small>` : ''}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(alertDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Dashboard] Error updating alerts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setElementText(id, value) {
|
||||||
|
const elem = document.getElementById(id);
|
||||||
|
if (elem) {
|
||||||
|
elem.textContent = (value !== undefined && value !== null && value !== '') ? value : '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes, decimals = 2) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.dashboard = new GPONDashboard();
|
||||||
|
});
|
||||||
50
templates/base.html
Normal file
50
templates/base.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pl" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}GPON Monitor{% endblock %}</title>
|
||||||
|
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/">
|
||||||
|
<i class="bi bi-diagram-3"></i> GPON Monitor
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<span class="nav-link" id="status-indicator">
|
||||||
|
<i class="bi bi-circle-fill text-secondary"></i> Checking...
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container-fluid py-4">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="text-center text-muted py-3 mt-5">
|
||||||
|
<small>GPON Monitor v1.0 | LXT-010S-H/H-D | linuxiarz.pl</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
361
templates/index.html
Normal file
361
templates/index.html
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Alerts Container -->
|
||||||
|
<div id="alerts-container" class="mb-4"></div>
|
||||||
|
|
||||||
|
<!-- Global Period Selector -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-calendar-range me-2"></i>
|
||||||
|
<strong>Global Time Range</strong>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm" role="group" id="global-period-selector">
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-1h" value="1h" checked>
|
||||||
|
<label class="btn btn-outline-primary" for="period-1h">1h</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-6h" value="6h">
|
||||||
|
<label class="btn btn-outline-primary" for="period-6h">6h</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-12h" value="12h">
|
||||||
|
<label class="btn btn-outline-primary" for="period-12h">12h</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-24h" value="24h">
|
||||||
|
<label class="btn btn-outline-primary" for="period-24h">24h</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-3d" value="3d">
|
||||||
|
<label class="btn btn-outline-primary" for="period-3d">3d</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-7d" value="7d">
|
||||||
|
<label class="btn btn-outline-primary" for="period-7d">7d</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-14d" value="14d">
|
||||||
|
<label class="btn btn-outline-primary" for="period-14d">14d</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-30d" value="30d">
|
||||||
|
<label class="btn btn-outline-primary" for="period-30d">30d</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-60d" value="60d">
|
||||||
|
<label class="btn btn-outline-primary" for="period-60d">60d</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-90d" value="90d">
|
||||||
|
<label class="btn btn-outline-primary" for="period-90d">90d</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-120d" value="120d">
|
||||||
|
<label class="btn btn-outline-primary" for="period-120d">120d</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-1y" value="1y">
|
||||||
|
<label class="btn btn-outline-primary" for="period-1y">1Y</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-2y" value="2y">
|
||||||
|
<label class="btn btn-outline-primary" for="period-2y">2Y</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="global-period" id="period-5y" value="5y">
|
||||||
|
<label class="btn btn-outline-primary" for="period-5y">5Y</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Metrics Row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card metric-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-arrow-down-circle fs-1 text-info"></i>
|
||||||
|
<h6 class="card-title mt-2">RX Power</h6>
|
||||||
|
<div class="metric-value" id="rx-power">--</div>
|
||||||
|
<span class="metric-unit">dBm</span>
|
||||||
|
<div class="progress mt-2" style="height: 5px;">
|
||||||
|
<div id="rx-power-bar" class="progress-bar bg-info" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card metric-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-arrow-up-circle fs-1 text-warning"></i>
|
||||||
|
<h6 class="card-title mt-2">TX Power</h6>
|
||||||
|
<div class="metric-value" id="tx-power">--</div>
|
||||||
|
<span class="metric-unit">dBm</span>
|
||||||
|
<div class="progress mt-2" style="height: 5px;">
|
||||||
|
<div id="tx-power-bar" class="progress-bar bg-warning" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card metric-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-thermometer-half fs-1 text-danger"></i>
|
||||||
|
<h6 class="card-title mt-2">Temperature</h6>
|
||||||
|
<div class="metric-value" id="temperature">--</div>
|
||||||
|
<span class="metric-unit">°C</span>
|
||||||
|
<div class="progress mt-2" style="height: 5px;">
|
||||||
|
<div id="temp-bar" class="progress-bar bg-danger" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card metric-card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-clock-history fs-1 text-success"></i>
|
||||||
|
<h6 class="card-title mt-2">Uptime</h6>
|
||||||
|
<div class="metric-value fs-5" id="uptime">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OLT Information Row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-hdd-network"></i> Device & OLT Information</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<h6 class="text-muted mb-3"><i class="bi bi-router"></i> ONU Device</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr><td class="text-muted border-0" style="width: 50%;">Vendor ID:</td><td class="border-0 fw-semibold" id="vendor-id">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">Serial Number:</td><td class="border-0 fw-semibold" id="serial-number">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">Version:</td><td class="border-0 fw-semibold" id="version">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">MAC Address:</td><td class="border-0 fw-semibold font-monospace" id="mac-address">--</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<h6 class="text-muted mb-3"><i class="bi bi-hdd-network-fill"></i> OLT Info</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr><td class="text-muted border-0" style="width: 50%;">OLT Vendor:</td><td class="border-0 fw-semibold" id="olt-vendor-info">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">OLT Version:</td><td class="border-0 fw-semibold" id="olt-version-info">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">Connection Time:</td><td class="border-0 fw-semibold" id="connection-time">--</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<h6 class="text-muted mb-3"><i class="bi bi-bar-chart-line"></i> Statistics</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr><td class="text-muted border-0" style="width: 50%;">RX Packets:</td><td class="border-0 fw-semibold" id="rx-packets">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">TX Packets:</td><td class="border-0 fw-semibold" id="tx-packets">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">FEC Corrected:</td><td class="border-0 fw-semibold" id="fec-corrected">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">FEC Uncorrected:</td><td class="border-0 fw-semibold" id="fec-uncorrected">--</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<h6 class="text-muted mb-3"><i class="bi bi-info-circle"></i> Diagnostics</h6>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr><td class="text-muted border-0" style="width: 50%;">Voltage:</td><td class="border-0 fw-semibold" id="voltage">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">TX Bias:</td><td class="border-0 fw-semibold" id="tx-bias">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">Data Volume:</td><td class="border-0 fw-semibold" id="data-volume-total">--</td></tr>
|
||||||
|
<tr><td class="text-muted border-0">Last Update:</td><td class="border-0 fw-semibold" id="last-update">--</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts Row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Optical Power -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-graph-up"></i> Optical Power</span>
|
||||||
|
<select class="form-select form-select-sm w-auto" id="period-optical">
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="6h">6h</option>
|
||||||
|
<option value="12h">12h</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
<option value="3d">3d</option>
|
||||||
|
<option value="7d">7d</option>
|
||||||
|
<option value="14d">14d</option>
|
||||||
|
<option value="30d">30d</option>
|
||||||
|
<option value="60d">60d</option>
|
||||||
|
<option value="90d">90d</option>
|
||||||
|
<option value="120d">120d</option>
|
||||||
|
<option value="1y">1 Year</option>
|
||||||
|
<option value="2y">2 Years</option>
|
||||||
|
<option value="5y">5 Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="chart-optical" height="250"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Temperature -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-thermometer-half"></i> Temperature</span>
|
||||||
|
<select class="form-select form-select-sm w-auto" id="period-temperature">
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="6h">6h</option>
|
||||||
|
<option value="12h">12h</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
<option value="3d">3d</option>
|
||||||
|
<option value="7d">7d</option>
|
||||||
|
<option value="14d">14d</option>
|
||||||
|
<option value="30d">30d</option>
|
||||||
|
<option value="60d">60d</option>
|
||||||
|
<option value="90d">90d</option>
|
||||||
|
<option value="120d">120d</option>
|
||||||
|
<option value="1y">1 Year</option>
|
||||||
|
<option value="2y">2 Years</option>
|
||||||
|
<option value="5y">5 Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="chart-temperature" height="250"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Traffic -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-activity"></i> Traffic</span>
|
||||||
|
<select class="form-select form-select-sm w-auto" id="period-traffic">
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="6h">6h</option>
|
||||||
|
<option value="12h">12h</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
<option value="3d">3d</option>
|
||||||
|
<option value="7d">7d</option>
|
||||||
|
<option value="14d">14d</option>
|
||||||
|
<option value="30d">30d</option>
|
||||||
|
<option value="60d">60d</option>
|
||||||
|
<option value="90d">90d</option>
|
||||||
|
<option value="120d">120d</option>
|
||||||
|
<option value="1y">1 Year</option>
|
||||||
|
<option value="2y">2 Years</option>
|
||||||
|
<option value="5y">5 Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="chart-traffic" height="250"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FEC Errors & Bytes Traffic Row -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Bytes Traffic -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-speedometer2"></i> Bytes Traffic (Speed)</span>
|
||||||
|
<select class="form-select form-select-sm w-auto" id="period-bytes">
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="6h">6h</option>
|
||||||
|
<option value="12h">12h</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
<option value="3d">3d</option>
|
||||||
|
<option value="7d">7d</option>
|
||||||
|
<option value="14d">14d</option>
|
||||||
|
<option value="30d">30d</option>
|
||||||
|
<option value="60d">60d</option>
|
||||||
|
<option value="90d">90d</option>
|
||||||
|
<option value="120d">120d</option>
|
||||||
|
<option value="1y">1 Year</option>
|
||||||
|
<option value="2y">2 Years</option>
|
||||||
|
<option value="5y">5 Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="chart-bytes" height="150"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FEC Errors -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-exclamation-triangle"></i> FEC Errors History</span>
|
||||||
|
<select class="form-select form-select-sm w-auto" id="period-fec">
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="6h">6h</option>
|
||||||
|
<option value="12h">12h</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
<option value="3d">3d</option>
|
||||||
|
<option value="7d">7d</option>
|
||||||
|
<option value="14d">14d</option>
|
||||||
|
<option value="30d">30d</option>
|
||||||
|
<option value="60d">60d</option>
|
||||||
|
<option value="90d">90d</option>
|
||||||
|
<option value="120d">120d</option>
|
||||||
|
<option value="1y">1 Year</option>
|
||||||
|
<option value="2y">2 Years</option>
|
||||||
|
<option value="5y">5 Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="chart-fec" height="150"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Volume Row -->
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-database"></i> Data Volume (Cumulative)</span>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<!-- Przełącznik Line/Bar -->
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="volume-chart-type" id="volume-line" value="line" checked>
|
||||||
|
<label class="btn btn-outline-secondary" for="volume-line">
|
||||||
|
<i class="bi bi-graph-up"></i> Line
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input type="radio" class="btn-check" name="volume-chart-type" id="volume-bar" value="bar">
|
||||||
|
<label class="btn btn-outline-secondary" for="volume-bar">
|
||||||
|
<i class="bi bi-bar-chart"></i> Bar
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Period selector -->
|
||||||
|
<select class="form-select form-select-sm w-auto" id="period-volume">
|
||||||
|
<option value="1h" selected>1h</option>
|
||||||
|
<option value="6h">6h</option>
|
||||||
|
<option value="12h">12h</option>
|
||||||
|
<option value="24h">24h</option>
|
||||||
|
<option value="3d">3d</option>
|
||||||
|
<option value="7d">7d</option>
|
||||||
|
<option value="14d">14d</option>
|
||||||
|
<option value="30d">30d</option>
|
||||||
|
<option value="60d">60d</option>
|
||||||
|
<option value="90d">90d</option>
|
||||||
|
<option value="120d">120d</option>
|
||||||
|
<option value="1y">1 Year</option>
|
||||||
|
<option value="2y">2 Years</option>
|
||||||
|
<option value="5y">5 Years</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="chart-volume" height="150"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user