Build Nginx live traffic monitor
This commit is contained in:
parent
2da9b83907
commit
fb66a69bd7
9 changed files with 525 additions and 2 deletions
94
README.md
94
README.md
|
|
@ -1,3 +1,93 @@
|
||||||
# nginx-realtime-traffic-dashboard
|
# Nginx Live Traffic Monitor
|
||||||
|
|
||||||
Shows realtime traffic flows for an nginx reverse proxy per server.
|

|
||||||
|
|
||||||
|
A small Python/FastAPI dashboard for seeing live traffic through an Nginx reverse proxy. It follows one dedicated access log, aggregates requests into bounded one-second in-memory buckets, and streams telemetry to browsers over Server-Sent Events. There is no database or persistent application telemetry.
|
||||||
|
|
||||||
|
## Requirements and installation
|
||||||
|
|
||||||
|
Python 3.10+ is required. From a checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The dashboard uses Bulma and Chart.js from their public CDNs. For an offline deployment, vendor those two assets and update `app/static/index.html`.
|
||||||
|
|
||||||
|
## Nginx log format
|
||||||
|
|
||||||
|
Add the dedicated log (the application does not change Nginx configuration):
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
log_format traffic '$msec $host $request_method $status '
|
||||||
|
'$request_length $bytes_sent '
|
||||||
|
'$request_time $upstream_response_time';
|
||||||
|
|
||||||
|
access_log /var/log/nginx-traffic/traffic.log traffic;
|
||||||
|
```
|
||||||
|
|
||||||
|
The follower tolerates malformed lines, `-` upstream times, multiple upstream values, temporary disappearance, and rename/replacement rotation. It starts at the end of an existing file, like `tail -F`, so old log history is not mistaken for live traffic. The application keeps only configured rolling buckets (and bounded latency samples); totals are from application startup.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://127.0.0.1:8080>. Configuration is available through flags or environment variables:
|
||||||
|
|
||||||
|
| Setting | Flag | Environment variable | Default |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Traffic log | `--log-path` | `TRAFFIC_LOG_PATH` | `/var/log/nginx-traffic/traffic.log` |
|
||||||
|
| Listen address | `--host` | `LISTEN_ADDRESS` | `127.0.0.1` |
|
||||||
|
| Listen port | `--port` | `LISTEN_PORT` | `8080` |
|
||||||
|
| Retention seconds | `--retention` | `TELEMETRY_RETENTION` | `3600` |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TRAFFIC_LOG_PATH=/var/log/nginx-traffic/traffic.log LISTEN_ADDRESS=0.0.0.0 LISTEN_PORT=8080 \
|
||||||
|
python -m app.main --retention 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
The visible window can be set to 1 minute, 5 minutes, 15 minutes, or 1 hour. A new browser receives enough current history to fill its selected graph window. Host names are discovered automatically and can be selected for breakout graphs. Theme selection (system, light, dark) is stored in browser local storage.
|
||||||
|
|
||||||
|
## tmpfs and permissions
|
||||||
|
|
||||||
|
One possible tmpfs setup is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -o nginx -g nginx -m 0750 /var/log/nginx-traffic
|
||||||
|
sudo mount -t tmpfs -o size=64M,mode=0750,uid=nginx,gid=nginx tmpfs /var/log/nginx-traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
The service account needs search/read permission on the directory and read permission on `traffic.log`. If Nginx writes as another group, grant the monitor account group access or use an ACL, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo setfacl -m u:nginx-monitor:rx /var/log/nginx-traffic
|
||||||
|
sudo setfacl -m u:nginx-monitor:r /var/log/nginx-traffic/traffic.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## systemd
|
||||||
|
|
||||||
|
Copy the included `nginx-traffic-monitor.service` to `/etc/systemd/system/`, adjust `WorkingDirectory`, virtualenv path, and the `User`/`Group` to match the installation, then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now nginx-traffic-monitor
|
||||||
|
sudo systemctl status nginx-traffic-monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
The service should run as an unprivileged account. Put a reverse proxy or firewall in front of it if it must be accessed beyond localhost; this utility intentionally has no authentication.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run the focused parser/aggregation tests with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
The server task follows the path by inode, drains currently available data from a renamed file, and reopens a replacement. A missing path is retried without busy polling. The publisher sends one compact snapshot per second to each SSE subscriber, and disconnecting clients are removed automatically.
|
||||||
|
|
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Nginx live traffic monitor."""
|
||||||
305
app/main.py
Normal file
305
app/main.py
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LOG = "/var/log/nginx-traffic/traffic.log"
|
||||||
|
MAX_HOSTS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Bucket:
|
||||||
|
incoming: int = 0
|
||||||
|
outgoing: int = 0
|
||||||
|
requests: int = 0
|
||||||
|
statuses: list[int] = field(default_factory=lambda: [0, 0, 0, 0])
|
||||||
|
request_times: list[float] = field(default_factory=list)
|
||||||
|
upstream_times: list[float] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Series:
|
||||||
|
def __init__(self, retention: int):
|
||||||
|
self.retention = retention
|
||||||
|
self.buckets: dict[int, Bucket] = {}
|
||||||
|
|
||||||
|
def bucket(self, second: int) -> Bucket:
|
||||||
|
if second not in self.buckets:
|
||||||
|
self.buckets[second] = Bucket()
|
||||||
|
cutoff = second - self.retention + 1
|
||||||
|
for old in [key for key in self.buckets if key < cutoff]:
|
||||||
|
del self.buckets[old]
|
||||||
|
return self.buckets[second]
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(values: list[float], p: float) -> float | None:
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
values = sorted(values)
|
||||||
|
index = (len(values) - 1) * p
|
||||||
|
low, high = math.floor(index), math.ceil(index)
|
||||||
|
if low == high:
|
||||||
|
return values[low]
|
||||||
|
return values[low] + (values[high] - values[low]) * (index - low)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_line(line: str) -> dict[str, Any] | None:
|
||||||
|
"""Parse the configured, whitespace-delimited Nginx traffic format."""
|
||||||
|
fields = line.strip().split()
|
||||||
|
if len(fields) < 8:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
timestamp = float(fields[0])
|
||||||
|
status = int(fields[3])
|
||||||
|
request_length = int(fields[4])
|
||||||
|
bytes_sent = int(fields[5])
|
||||||
|
request_time = float(fields[6])
|
||||||
|
upstream_raw = "".join(fields[7:])
|
||||||
|
upstream_values = [float(value) for value in upstream_raw.split(",") if value not in ("", "-")]
|
||||||
|
if not all(math.isfinite(value) and value >= 0 for value in
|
||||||
|
(timestamp, request_length, bytes_sent, request_time, *upstream_values)):
|
||||||
|
return None
|
||||||
|
if status < 100 or status > 599:
|
||||||
|
return None
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"host": fields[1] or "(unknown)",
|
||||||
|
"incoming": request_length,
|
||||||
|
"outgoing": bytes_sent,
|
||||||
|
"status": status,
|
||||||
|
"request_time": request_time,
|
||||||
|
"upstream_time": statistics.fmean(upstream_values) if upstream_values else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry:
|
||||||
|
def __init__(self, retention: int):
|
||||||
|
self.retention = max(60, min(retention, 86400))
|
||||||
|
self.global_series = Series(self.retention)
|
||||||
|
self.hosts: dict[str, Series] = {}
|
||||||
|
self.last_seen: dict[str, int] = {}
|
||||||
|
self.total_incoming = 0
|
||||||
|
self.total_outgoing = 0
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.subscribers: set[asyncio.Queue[str]] = set()
|
||||||
|
|
||||||
|
def ingest(self, event: dict[str, Any]) -> None:
|
||||||
|
now = int(time.time())
|
||||||
|
second = int(event["timestamp"])
|
||||||
|
# Log timestamps are normally current, but clamping protects bounded
|
||||||
|
# storage if a clock-jumped or otherwise surprising line appears.
|
||||||
|
if second < now - self.retention:
|
||||||
|
return
|
||||||
|
second = min(second, now)
|
||||||
|
with self.lock:
|
||||||
|
self._add(self.global_series.bucket(second), event)
|
||||||
|
host = event["host"]
|
||||||
|
if host not in self.hosts:
|
||||||
|
if len(self.hosts) >= MAX_HOSTS:
|
||||||
|
oldest = min(self.last_seen, key=self.last_seen.get)
|
||||||
|
self.hosts.pop(oldest, None)
|
||||||
|
self.last_seen.pop(oldest, None)
|
||||||
|
self.hosts[host] = Series(self.retention)
|
||||||
|
self._add(self.hosts[host].bucket(second), event)
|
||||||
|
self.last_seen[host] = second
|
||||||
|
self.total_incoming += event["incoming"]
|
||||||
|
self.total_outgoing += event["outgoing"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add(bucket: Bucket, event: dict[str, Any]) -> None:
|
||||||
|
bucket.incoming += event["incoming"]
|
||||||
|
bucket.outgoing += event["outgoing"]
|
||||||
|
bucket.requests += 1
|
||||||
|
status_class = event["status"] // 100
|
||||||
|
if 2 <= status_class <= 5:
|
||||||
|
bucket.statuses[status_class - 2] += 1
|
||||||
|
if len(bucket.request_times) < 256:
|
||||||
|
bucket.request_times.append(event["request_time"])
|
||||||
|
if event["upstream_time"] is not None and len(bucket.upstream_times) < 256:
|
||||||
|
bucket.upstream_times.append(event["upstream_time"])
|
||||||
|
|
||||||
|
def subscribe(self) -> asyncio.Queue[str]:
|
||||||
|
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=2)
|
||||||
|
with self.lock:
|
||||||
|
self.subscribers.add(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
def unsubscribe(self, queue: asyncio.Queue[str]) -> None:
|
||||||
|
with self.lock:
|
||||||
|
self.subscribers.discard(queue)
|
||||||
|
|
||||||
|
def snapshot(self, window: int) -> dict[str, Any]:
|
||||||
|
now = int(time.time())
|
||||||
|
start = now - window + 1
|
||||||
|
with self.lock:
|
||||||
|
points = [self._point(self.global_series.buckets.get(second), second) for second in range(start, now + 1)]
|
||||||
|
hosts = sorted(self.hosts, key=lambda host: self.last_seen.get(host, 0), reverse=True)
|
||||||
|
active = [host for host in hosts if self.last_seen.get(host, 0) >= now - 59]
|
||||||
|
host_points = {
|
||||||
|
host: [self._point(self.hosts[host].buckets.get(second), second) for second in range(start, now + 1)]
|
||||||
|
for host in hosts
|
||||||
|
}
|
||||||
|
current = self._summary(points[-60:])
|
||||||
|
current["total_incoming"] = self.total_incoming
|
||||||
|
current["total_outgoing"] = self.total_outgoing
|
||||||
|
current["active_hosts"] = len(active)
|
||||||
|
current["busiest_host"] = max(active, key=lambda host: sum(p["incoming"] + p["outgoing"] for p in host_points[host][-60:]), default=None)
|
||||||
|
return {"type": "telemetry", "now": now, "points": points, "hosts": hosts, "host_points": host_points, "summary": current}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _point(bucket: Bucket | None, second: int) -> dict[str, Any]:
|
||||||
|
if not bucket:
|
||||||
|
return {"time": second, "incoming": 0, "outgoing": 0, "requests": 0, "status": [0, 0, 0, 0], "latency": None, "p95": None, "upstream": None}
|
||||||
|
return {"time": second, "incoming": bucket.incoming, "outgoing": bucket.outgoing, "requests": bucket.requests, "status": bucket.statuses, "latency": statistics.fmean(bucket.request_times) if bucket.request_times else None, "p95": percentile(bucket.request_times, .95), "upstream": statistics.fmean(bucket.upstream_times) if bucket.upstream_times else None}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _summary(points: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
seconds = max(1, len(points))
|
||||||
|
total_requests = sum(point["requests"] for point in points)
|
||||||
|
statuses = [sum(point["status"][index] for point in points) for index in range(4)]
|
||||||
|
latencies = [point["latency"] for point in points if point["latency"] is not None]
|
||||||
|
upstream = [point["upstream"] for point in points if point["upstream"] is not None]
|
||||||
|
return {"incoming": sum(point["incoming"] for point in points) / seconds, "outgoing": sum(point["outgoing"] for point in points) / seconds, "requests": total_requests / seconds, "status": statuses, "latency": statistics.fmean(latencies) if latencies else None, "p95": percentile(latencies, .95), "upstream": statistics.fmean(upstream) if upstream else None}
|
||||||
|
|
||||||
|
|
||||||
|
async def follow_log(path: str, telemetry: Telemetry, stop: asyncio.Event) -> None:
|
||||||
|
handle = None
|
||||||
|
inode = None
|
||||||
|
pending = ""
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
stat = await asyncio.to_thread(os.stat, path)
|
||||||
|
if handle is None:
|
||||||
|
handle = await asyncio.to_thread(open, path, "r", encoding="utf-8", errors="replace")
|
||||||
|
await asyncio.to_thread(handle.seek, 0, os.SEEK_END)
|
||||||
|
inode = (stat.st_dev, stat.st_ino)
|
||||||
|
elif inode != (stat.st_dev, stat.st_ino):
|
||||||
|
# Drain what is currently available from the renamed file before switching.
|
||||||
|
for line in handle:
|
||||||
|
event = parse_line(line)
|
||||||
|
if event:
|
||||||
|
telemetry.ingest(event)
|
||||||
|
handle.close()
|
||||||
|
handle = await asyncio.to_thread(open, path, "r", encoding="utf-8", errors="replace")
|
||||||
|
inode = (stat.st_dev, stat.st_ino)
|
||||||
|
# Never join a partial line from the old inode to a new file.
|
||||||
|
pending = ""
|
||||||
|
data = await asyncio.to_thread(handle.read)
|
||||||
|
if data:
|
||||||
|
pending += data
|
||||||
|
lines = pending.split("\n")
|
||||||
|
pending = lines.pop()
|
||||||
|
for line in lines:
|
||||||
|
event = parse_line(line)
|
||||||
|
if event:
|
||||||
|
telemetry.ingest(event)
|
||||||
|
await asyncio.sleep(.15)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if handle:
|
||||||
|
data = handle.read()
|
||||||
|
if data:
|
||||||
|
pending += data
|
||||||
|
await asyncio.sleep(.5)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
if handle:
|
||||||
|
handle.close()
|
||||||
|
handle, inode = None, None
|
||||||
|
await asyncio.sleep(.5)
|
||||||
|
if handle:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(log_path: str = DEFAULT_LOG, retention: int = 3600) -> FastAPI:
|
||||||
|
telemetry = Telemetry(retention)
|
||||||
|
stop = asyncio.Event()
|
||||||
|
app = FastAPI(title="Nginx Live Traffic Monitor")
|
||||||
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup() -> None:
|
||||||
|
app.state.task = asyncio.create_task(follow_log(log_path, telemetry, stop))
|
||||||
|
app.state.publisher = asyncio.create_task(publish())
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def shutdown() -> None:
|
||||||
|
stop.set()
|
||||||
|
for task in (getattr(app.state, "task", None), getattr(app.state, "publisher", None)):
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
async def publish() -> None:
|
||||||
|
while not stop.is_set():
|
||||||
|
payload = json.dumps(telemetry.snapshot(3600), separators=(",", ":"))
|
||||||
|
with telemetry.lock:
|
||||||
|
subscribers = list(telemetry.subscribers)
|
||||||
|
for queue in subscribers:
|
||||||
|
try:
|
||||||
|
queue.put_nowait(payload)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
try:
|
||||||
|
queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
queue.put_nowait(payload)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index() -> FileResponse:
|
||||||
|
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
||||||
|
|
||||||
|
@app.get("/api/telemetry")
|
||||||
|
async def telemetry_api(window: int = 300) -> dict[str, Any]:
|
||||||
|
return telemetry.snapshot(max(60, min(window, 3600)))
|
||||||
|
|
||||||
|
@app.get("/api/events")
|
||||||
|
async def events(request: Request, window: int = 300) -> StreamingResponse:
|
||||||
|
queue = telemetry.subscribe()
|
||||||
|
initial = json.dumps(telemetry.snapshot(max(60, min(window, 3600))), separators=(",", ":"))
|
||||||
|
async def stream() -> AsyncIterator[str]:
|
||||||
|
try:
|
||||||
|
yield f"data: {initial}\n\n"
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
payload = await asyncio.wait_for(queue.get(), timeout=15)
|
||||||
|
yield f"data: {payload}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ": keep-alive\n\n"
|
||||||
|
finally:
|
||||||
|
telemetry.unsubscribe(queue)
|
||||||
|
return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
app.state.telemetry = telemetry
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Live Nginx traffic dashboard")
|
||||||
|
parser.add_argument("--log-path", default=os.getenv("TRAFFIC_LOG_PATH", DEFAULT_LOG))
|
||||||
|
parser.add_argument("--host", default=os.getenv("LISTEN_ADDRESS", "127.0.0.1"))
|
||||||
|
parser.add_argument("--port", type=int, default=int(os.getenv("LISTEN_PORT", "8080")))
|
||||||
|
parser.add_argument("--retention", type=int, default=int(os.getenv("TELEMETRY_RETENTION", "3600")))
|
||||||
|
args = parser.parse_args()
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(create_app(args.log_path, args.retention), host=args.host, port=args.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
54
app/static/index.html
Normal file
54
app/static/index.html
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="system">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Nginx Live Traffic Monitor</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||||
|
<style>
|
||||||
|
:root { --chart-grid: #d9e1ea; }
|
||||||
|
[data-theme="dark"] { --chart-grid: #3b4654; }
|
||||||
|
body { background: var(--bulma-scheme-main-bis); min-height: 100vh; }
|
||||||
|
.navbar, .card { background: var(--bulma-scheme-main); }
|
||||||
|
.chart-wrap { height: 390px; position: relative; }
|
||||||
|
.metric-value { font-size: 1.35rem; font-weight: 700; white-space: nowrap; }
|
||||||
|
.metric-label { color: var(--bulma-text-weak); font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.host-list { max-height: 185px; overflow-y: auto; }
|
||||||
|
.legend-dot { width: .65rem; height: .65rem; display: inline-block; border-radius: 50%; margin-right: .4rem; }
|
||||||
|
@media (max-width: 768px) { .chart-wrap { height: 280px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar is-shadowless" role="navigation"><div class="container">
|
||||||
|
<div class="navbar-brand"><div class="navbar-item"><strong>Nginx Live Traffic</strong></div></div>
|
||||||
|
<div class="navbar-end"><div class="navbar-item"><div class="select is-small"><select id="theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></div></div></div>
|
||||||
|
</div></nav>
|
||||||
|
<main class="section pt-5"><div class="container">
|
||||||
|
<div class="level mb-4"><div><h1 class="title is-4 mb-1">Traffic overview</h1><p class="subtitle is-6 mb-0" id="connection">Connecting to telemetry…</p></div>
|
||||||
|
<div class="field has-addons"><p class="control"><span class="select is-small"><select id="window"><option value="60">1 minute</option><option value="300" selected>5 minutes</option><option value="900">15 minutes</option><option value="3600">1 hour</option></select></span></p></div>
|
||||||
|
</div>
|
||||||
|
<div class="columns is-multiline mb-2" id="metrics"></div>
|
||||||
|
<div class="card mb-5"><div class="card-content"><div class="level mb-3"><div class="level-left"><h2 class="title is-5 mb-0">Throughput</h2></div><div class="level-right"><span class="tag is-light" id="updated">Waiting for data</span></div></div><div class="chart-wrap"><canvas id="traffic-chart"></canvas></div></div></div>
|
||||||
|
<div class="card"><div class="card-content"><div class="columns"><div class="column is-one-quarter"><h2 class="title is-5">Host breakouts</h2><p class="help mb-3">Select hosts to compare their recent traffic.</p><div id="hosts" class="host-list"></div></div><div class="column"><div class="chart-wrap"><canvas id="host-chart"></canvas></div></div></div></div></div>
|
||||||
|
</div></main>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const palette = ['#3273dc','#23d160','#ff3860','#ffdd57','#7957d5','#00d1b2','#f14668','#209cee'];
|
||||||
|
let selected = new Set(), latest = null, source;
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
function bytesPerSecond(value) { const bits = value * 8; if (bits >= 1e9) return (bits/1e9).toFixed(1)+' Gbit/s'; if (bits >= 1e6) return (bits/1e6).toFixed(1)+' Mbit/s'; if (bits >= 1e3) return (bits/1e3).toFixed(1)+' Kbit/s'; return Math.round(bits)+' bit/s'; }
|
||||||
|
function duration(v) { return v == null ? '—' : v.toFixed(3)+' s'; }
|
||||||
|
function applyTheme(value) { const actual = value === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : value; document.documentElement.dataset.theme = actual; document.documentElement.style.colorScheme = actual; if (typeof trafficChart !== 'undefined') { trafficChart.options.scales.x.grid.color = getComputedStyle(document.documentElement).getPropertyValue('--chart-grid'); hostChart.options.scales.x.grid.color = trafficChart.options.scales.x.grid.color; trafficChart.update(); hostChart.update(); } }
|
||||||
|
function metric(label, value) { return `<div class="column is-half-mobile is-one-quarter-tablet"><div class="card"><div class="card-content py-4"><div class="metric-label">${label}</div><div class="metric-value">${value}</div></div></div></div>`; }
|
||||||
|
function renderMetrics(s) { const status = s.status.reduce((a,b)=>a+b,0); const errors = status ? ((s.status[2]+s.status[3])/status*100).toFixed(1)+'%' : '0.0%'; $('metrics').innerHTML = metric('Inbound', bytesPerSecond(s.incoming))+metric('Outbound', bytesPerSecond(s.outgoing))+metric('Requests', s.requests.toFixed(1)+'/sec')+metric('Transferred', formatBytes(s.total_incoming+s.total_outgoing))+metric('Avg latency', duration(s.latency))+metric('p95 latency', duration(s.p95))+metric('Upstream avg', duration(s.upstream))+metric('Errors (4xx/5xx)', errors)+metric('Active hosts', s.active_hosts)+metric('Busiest host', s.busiest_host || '—'); }
|
||||||
|
function formatBytes(n) { const units=['B','KB','MB','GB','TB']; let i=0; while(n>=1000 && i<units.length-1){n/=1000;i++;} return (i? n.toFixed(1):Math.round(n))+' '+units[i]; }
|
||||||
|
function makeChart(id, datasets) { return new Chart($(id), {type:'line', data:{labels:[],datasets}, options:{animation:false, maintainAspectRatio:false, interaction:{mode:'index',intersect:false}, scales:{x:{grid:{color:getComputedStyle(document.documentElement).getPropertyValue('--chart-grid')},ticks:{maxTicksLimit:8}},y:{beginAtZero:true,ticks:{callback:v=>bytesPerSecond(v)}}}, plugins:{legend:{display:true}}}}); }
|
||||||
|
const trafficChart = makeChart('traffic-chart',[{label:'Inbound',data:[],borderColor:'#3273dc',backgroundColor:'#3273dc',tension:.25,pointRadius:0},{label:'Outbound',data:[],borderColor:'#23d160',backgroundColor:'#23d160',tension:.25,pointRadius:0}]);
|
||||||
|
const hostChart = makeChart('host-chart',[]);
|
||||||
|
function render(data) { latest=data; const windowSize=Number($('window').value); const points=data.points.slice(-windowSize); trafficChart.data.labels=points.map(p=>new Date(p.time*1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})); trafficChart.data.datasets[0].data=points.map(p=>p.incoming); trafficChart.data.datasets[1].data=points.map(p=>p.outgoing); trafficChart.update(); hostChart.data.labels=trafficChart.data.labels; hostChart.data.datasets=[...selected].map((host,i)=>({label:host,data:(data.host_points[host]||[]).slice(-windowSize).map(p=>p.incoming+p.outgoing),borderColor:palette[i%palette.length],tension:.25,pointRadius:0})); hostChart.update(); renderMetrics(data.summary); $('updated').textContent='Updated '+new Date().toLocaleTimeString(); }
|
||||||
|
function connect() { if(source) source.close(); source=new EventSource('/api/events?window='+$('window').value); source.onopen=()=>{$('connection').textContent='Live · updates every second';}; source.onmessage=e=>render(JSON.parse(e.data)); source.onerror=()=>{$('connection').textContent='Reconnecting…';}; }
|
||||||
|
function renderHosts(hosts) { $('hosts').innerHTML=hosts.map((host,i)=>`<label class="checkbox is-block mb-2"><input type="checkbox" value="${escapeHtml(host)}" ${selected.has(host)?'checked':''}> <span class="legend-dot" style="background:${palette[i%palette.length]}"></span>${escapeHtml(host)}</label>`).join('') || '<p class="has-text-grey">No hosts observed yet.</p>'; document.querySelectorAll('#hosts input').forEach(input=>input.onchange=()=>{input.checked?selected.add(input.value):selected.delete(input.value); if(latest) render(latest);}); }
|
||||||
|
function escapeHtml(s) { return s.replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
||||||
|
const oldRender=render; render=data=>{oldRender(data);renderHosts(data.hosts);};
|
||||||
|
$('theme').value=localStorage.getItem('nginx-theme')||'system'; applyTheme($('theme').value); $('theme').onchange=()=>{localStorage.setItem('nginx-theme',$('theme').value);applyTheme($('theme').value);}; $('window').onchange=connect; connect();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
14
docs/screenshot-placeholder.svg
Normal file
14
docs/screenshot-placeholder.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="650" viewBox="0 0 1200 650">
|
||||||
|
<rect width="1200" height="650" fill="#f5f7fa"/>
|
||||||
|
<rect x="40" y="30" width="1120" height="55" rx="6" fill="#fff" stroke="#d9e1ea"/>
|
||||||
|
<text x="70" y="66" font-family="sans-serif" font-size="22" font-weight="bold" fill="#363636">Nginx Live Traffic</text>
|
||||||
|
<rect x="40" y="110" width="1120" height="110" rx="6" fill="#fff" stroke="#d9e1ea"/>
|
||||||
|
<text x="70" y="145" font-family="sans-serif" font-size="13" fill="#7a7a7a">INBOUND</text><text x="70" y="180" font-family="sans-serif" font-size="26" font-weight="bold" fill="#363636">3.8 Mbit/s</text>
|
||||||
|
<text x="320" y="145" font-family="sans-serif" font-size="13" fill="#7a7a7a">OUTBOUND</text><text x="320" y="180" font-family="sans-serif" font-size="26" font-weight="bold" fill="#363636">42.3 Mbit/s</text>
|
||||||
|
<text x="600" y="145" font-family="sans-serif" font-size="13" fill="#7a7a7a">REQUESTS</text><text x="600" y="180" font-family="sans-serif" font-size="26" font-weight="bold" fill="#363636">183/sec</text>
|
||||||
|
<text x="40" y="275" font-family="sans-serif" font-size="21" font-weight="bold" fill="#363636">Throughput</text>
|
||||||
|
<rect x="40" y="295" width="1120" height="250" rx="6" fill="#fff" stroke="#d9e1ea"/>
|
||||||
|
<polyline points="80,480 180,430 280,455 380,370 480,410 580,330 680,390 780,320 880,360 980,300 1080,340" fill="none" stroke="#3273dc" stroke-width="4"/>
|
||||||
|
<polyline points="80,510 180,500 280,490 380,470 480,480 580,430 680,450 780,410 880,440 980,400 1080,420" fill="none" stroke="#23d160" stroke-width="4"/>
|
||||||
|
<text x="40" y="610" font-family="sans-serif" font-size="16" fill="#7a7a7a">Screenshot placeholder — run the app to view live data.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
17
nginx-traffic-monitor.service
Normal file
17
nginx-traffic-monitor.service
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Nginx live traffic monitor
|
||||||
|
After=network.target nginx.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nginx-monitor
|
||||||
|
Group=nginx-monitor
|
||||||
|
WorkingDirectory=/opt/nginx-live-traffic-monitor
|
||||||
|
ExecStart=/opt/nginx-live-traffic-monitor/.venv/bin/nginx-traffic-monitor --host 127.0.0.1 --port 8080
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
12
pyproject.toml
Normal file
12
pyproject.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[project]
|
||||||
|
name = "nginx-live-traffic-monitor"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Small in-memory live traffic dashboard for an Nginx reverse proxy"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["fastapi>=0.115,<1", "uvicorn[standard]>=0.30,<1"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = ["pytest>=8,<9"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
nginx-traffic-monitor = "app.main:main"
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fastapi>=0.115,<1
|
||||||
|
uvicorn[standard]>=0.30,<1
|
||||||
28
tests/test_main.py
Normal file
28
tests/test_main.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from app.main import Telemetry, parse_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_line_and_multiple_upstreams():
|
||||||
|
event = parse_line("1720000000.123 example.test GET 200 120 240 0.150 0.100, 0.200")
|
||||||
|
assert event["host"] == "example.test"
|
||||||
|
assert event["incoming"] == 120
|
||||||
|
assert event["upstream_time"] == 0.15
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_lines_are_ignored():
|
||||||
|
assert parse_line("not enough fields") is None
|
||||||
|
assert parse_line("1 host GET nope 1 2 0.1 -") is None
|
||||||
|
assert parse_line("1 host GET 600 1 2 0.1 -") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_telemetry_is_aggregated_and_bounded():
|
||||||
|
telemetry = Telemetry(60)
|
||||||
|
base = int(time.time()) - 30
|
||||||
|
for second in range(70):
|
||||||
|
telemetry.ingest(parse_line(f"{base + second} host GET 200 10 20 .1 -"))
|
||||||
|
assert telemetry.total_incoming == 700
|
||||||
|
assert len(telemetry.global_series.buckets) <= 60
|
||||||
|
snapshot = telemetry.snapshot(60)
|
||||||
|
assert snapshot["summary"]["requests"] >= 1
|
||||||
|
assert snapshot["hosts"] == ["host"]
|
||||||
Loading…
Reference in a new issue