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