From 69711b46bc60071d831e39deb7889edeb9e3d0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 2 Jan 2026 22:31:35 +0100 Subject: [PATCH] release --- .dockerignore | 12 + .env.example | 24 ++ .gitignore | 5 + Dockerfile | 37 +++ README.md | 190 +++++++++++ app.py | 179 +++++++++++ collector.py | 386 +++++++++++++++++++++++ config.py | 33 ++ docker-compose.yml | 28 ++ requirements.txt | 5 + rrd_manager.py | 365 ++++++++++++++++++++++ static/css/style.css | 102 ++++++ static/js/charts.js | 692 +++++++++++++++++++++++++++++++++++++++++ static/js/dashboard.js | 256 +++++++++++++++ templates/base.html | 50 +++ templates/index.html | 361 +++++++++++++++++++++ 16 files changed, 2725 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 collector.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 rrd_manager.py create mode 100644 static/css/style.css create mode 100644 static/js/charts.js create mode 100644 static/js/dashboard.js create mode 100644 templates/base.html create mode 100644 templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f22b67 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.env +.git +.gitignore +*.log +data/rrd/*.rrd +venv/ +env/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f240af1 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4969ae2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +env +venv +.env +/data/* +__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43c4618 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f256d1 --- /dev/null +++ b/README.md @@ -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 +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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..abd45cd --- /dev/null +++ b/app.py @@ -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//') +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 + ) diff --git a/collector.py b/collector.py new file mode 100644 index 0000000..367e074 --- /dev/null +++ b/collector.py @@ -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']*>Temperature\s*]*>([\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']*>Voltage\s*]*>([\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']*>Bias Current\s*]*>([\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']*>Uptime\s*]*>\s*([^<]+)', + 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']*>Packets Sent:\s*]*>(\d+)', 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']*>Packets Received:\s*]*>(\d+)', 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']*>Bytes Sent:\s*]*>(\d+)', 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']*>Bytes Received:\s*]*>(\d+)', 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") diff --git a/config.py b/config.py new file mode 100644 index 0000000..f92b922 --- /dev/null +++ b/config.py @@ -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)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2abd107 --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c9ef510 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.0 +gunicorn==21.2.0 +python-dotenv==1.0.0 +telnetlib3==2.0.4 +aiohttp \ No newline at end of file diff --git a/rrd_manager.py b/rrd_manager.py new file mode 100644 index 0000000..21a8e7b --- /dev/null +++ b/rrd_manager.py @@ -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 + } diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..5d59e17 --- /dev/null +++ b/static/css/style.css @@ -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; + } +} diff --git a/static/js/charts.js b/static/js/charts.js new file mode 100644 index 0000000..bb88dff --- /dev/null +++ b/static/js/charts.js @@ -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(); +}); diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..2cc6575 --- /dev/null +++ b/static/js/dashboard.js @@ -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 = 'Online'; + } else { + statusElem.innerHTML = 'Offline'; + } + } + + 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 = ' Online'; + } else if (data.status === 'error') { + indicator.innerHTML = ' Error'; + } else { + indicator.innerHTML = ' 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 = ` + + ${category}: ${alert.message} + ${timestamp ? `${timestamp}` : ''} + + `; + + 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(); +}); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5ab416b --- /dev/null +++ b/templates/base.html @@ -0,0 +1,50 @@ + + + + + + {% block title %}GPON Monitor{% endblock %} + + + + + + {% block extra_css %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ +
+ GPON Monitor v1.0 | LXT-010S-H/H-D | linuxiarz.pl +
+ + + + + + + {% block extra_js %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c2d67bb --- /dev/null +++ b/templates/index.html @@ -0,0 +1,361 @@ +{% extends "base.html" %} + +{% block content %} + +
+ + +
+
+
+
+
+
+ + Global Time Range +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + +
+
+
+
+ +
RX Power
+
--
+ dBm +
+
+
+
+
+
+ +
+
+
+ +
TX Power
+
--
+ dBm +
+
+
+
+
+
+ +
+
+
+ +
Temperature
+
--
+ °C +
+
+
+
+
+
+ +
+
+
+ +
Uptime
+
--
+
+
+
+
+ + +
+
+
+
Device & OLT Information
+
+
+
+
ONU Device
+ + + + + +
Vendor ID:--
Serial Number:--
Version:--
MAC Address:--
+
+
+
OLT Info
+ + + + +
OLT Vendor:--
OLT Version:--
Connection Time:--
+
+
+
Statistics
+ + + + + +
RX Packets:--
TX Packets:--
FEC Corrected:--
FEC Uncorrected:--
+
+
+
Diagnostics
+ + + + + +
Voltage:--
TX Bias:--
Data Volume:--
Last Update:--
+
+
+
+
+
+
+ + +
+ +
+
+
+ Optical Power + +
+
+ +
+
+
+ + +
+
+
+ Temperature + +
+
+ +
+
+
+ + +
+
+
+ Traffic + +
+
+ +
+
+
+
+ + +
+ +
+
+
+ Bytes Traffic (Speed) + +
+
+ +
+
+
+ + +
+
+
+ FEC Errors History + +
+
+ +
+
+
+
+ + +
+
+
+
+ Data Volume (Cumulative) +
+ +
+ + + + + +
+ + + +
+
+
+ +
+
+
+
+ +{% endblock %}