commit 69711b46bc60071d831e39deb7889edeb9e3d0aa Author: Mateusz Gruszczyński Date: Fri Jan 2 22:31:35 2026 +0100 release 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 %}