This commit is contained in:
Mateusz Gruszczyński
2026-01-02 22:31:35 +01:00
commit 69711b46bc
16 changed files with 2725 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
.env
.git
.gitignore
*.log
data/rrd/*.rrd
venv/
env/

24
.env.example Normal file
View 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
View File

@@ -0,0 +1,5 @@
env
venv
.env
/data/*
__pycache__

37
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 %}