diff --git a/portspoof_py/__pycache__/admin.cpython-312.pyc b/portspoof_py/__pycache__/admin.cpython-312.pyc
index 0c47301..1c8dd72 100644
Binary files a/portspoof_py/__pycache__/admin.cpython-312.pyc and b/portspoof_py/__pycache__/admin.cpython-312.pyc differ
diff --git a/portspoof_py/__pycache__/server.cpython-312.pyc b/portspoof_py/__pycache__/server.cpython-312.pyc
index 684225f..93397d6 100644
Binary files a/portspoof_py/__pycache__/server.cpython-312.pyc and b/portspoof_py/__pycache__/server.cpython-312.pyc differ
diff --git a/portspoof_py/__pycache__/stats.cpython-312.pyc b/portspoof_py/__pycache__/stats.cpython-312.pyc
index 32c932a..e8df5c0 100644
Binary files a/portspoof_py/__pycache__/stats.cpython-312.pyc and b/portspoof_py/__pycache__/stats.cpython-312.pyc differ
diff --git a/portspoof_py/admin.py b/portspoof_py/admin.py
index ee45083..884c3ed 100644
--- a/portspoof_py/admin.py
+++ b/portspoof_py/admin.py
@@ -308,31 +308,12 @@ tr:hover td { background: #1c2128; }
border-radius: 3px; padding: 1px 7px; font-size: 11px;
margin-left: 6px; vertical-align: middle;
}
-/* lookup form */
-.form-row { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
-input[type=number] {
- background: #0d1117; border: 1px solid #30363d;
- color: #c9d1d9; padding: 6px 10px; border-radius: 4px;
- width: 110px; font-family: inherit; font-size: 13px;
-}
-button {
- background: #238636; border: 1px solid #2ea043;
- color: #fff; padding: 6px 14px; border-radius: 4px;
- cursor: pointer; font-family: inherit; font-size: 13px;
-}
-button:hover { background: #2ea043; }
-.lookup-result {
- background: #0d1117; border: 1px solid #30363d;
- border-radius: 4px; padding: 10px; margin-top: 8px;
-}
-.lookup-result .lbl { color: #8b949e; font-size: 11px; margin-bottom: 2px; }
-.lookup-result .hexval { color: #a5d6ff; word-break: break-all; }
-.lookup-result .txtval {
- color: #c9d1d9; white-space: pre; overflow-x: auto;
- max-height: 120px; font-size: 12px; margin-top: 6px;
- border-top: 1px solid #21262d; padding-top: 6px;
-}
.empty { color: #8b949e; font-style: italic; padding: 8px 0; }
+.link-btn {
+ background: none; border: none; color: #58a6ff; padding: 0;
+ font-family: inherit; font-size: inherit; cursor: pointer;
+}
+.link-btn:hover { text-decoration: underline; }
"""
_HTML = """\
@@ -349,8 +330,10 @@ _HTML = """\
portspoof admin
Auto-refreshes every 5 s ·
- reset lookup ·
- email alerts ·
+
+ · email alerts ·
JSON stats
@@ -363,7 +346,13 @@ _HTML = """\
Last connection
{last_seen}
-
+
+
+
Connections / min last 60 min
+ {cpm_chart}
+
+
+
Top source IPs
@@ -380,18 +369,6 @@ _HTML = """\
{top_ports_rows}
-
-
-
Banner lookup
-
- {lookup_html}
-
@@ -408,71 +385,111 @@ _HTML = """\
"""
+def _render_cpm_chart(history: list) -> str:
+ """Render a 60-minute connections-per-minute SVG line chart (server-side, no JS)."""
+ n = len(history)
+ # Plot area inside the SVG viewBox
+ PX, PY = 38, 8 # top-left corner of plot area
+ W, H = 572, 92 # plot area width and height
+ VW, VH = PX + W + 4, PY + H + 22 # total viewBox size
+
+ max_val = max(history) if any(history) else 0
+ y_max = max(max_val, 1)
+
+ parts: list = []
+
+ # Horizontal grid lines + Y-axis labels
+ for frac, label_fn in ((0.25, lambda v: str(int(v * 0.25))),
+ (0.5, lambda v: str(int(v * 0.5))),
+ (0.75, lambda v: str(int(v * 0.75))),
+ (1.0, lambda v: str(int(v)))):
+ gy = PY + H - int(H * frac)
+ lbl = label_fn(y_max)
+ parts.append(
+ f''
+ f'{lbl}'
+ )
+
+ # X-axis labels at fixed minute offsets
+ for idx, lbl in ((0, '-60m'), (15, '-45m'), (30, '-30m'), (45, '-15m'), (59, 'now')):
+ gx = PX + int(idx * W / (n - 1)) if n > 1 else PX
+ parts.append(
+ f'{lbl}'
+ )
+
+ # Axes
+ parts.append(
+ f''
+ f''
+ )
+
+ # Line + filled area
+ if n > 1:
+ coords = [
+ (PX + int(i * W / (n - 1)), PY + H - int(H * v / y_max))
+ for i, v in enumerate(history)
+ ]
+ line_pts = ' '.join(f'{x},{y}' for x, y in coords)
+ area_pts = f'{PX},{PY+H} ' + line_pts + f' {PX+W},{PY+H}'
+ parts.append(f'')
+ parts.append(
+ f''
+ )
+
+ return (
+ f''
+ )
+
+
def _empty_row(cols: int, msg: str) -> str:
return f'| {msg} |
'
-def _render_dashboard(
- stats: Stats,
- cfg: Config,
- port_q: Optional[str],
-) -> str:
+def _render_dashboard(stats: Stats, cfg: Config) -> str:
# ── top IPs ──
top_ips = stats.top_ips()
- if top_ips:
- ip_rows = ''.join(
+ ip_rows = (
+ ''.join(
f'| {html.escape(ip)} | {c} |
'
for ip, c in top_ips
- )
- else:
- ip_rows = _empty_row(2, 'no data yet')
+ ) if top_ips else _empty_row(2, 'no data yet')
+ )
# ── top ports ──
top_ports = stats.top_ports()
- if top_ports:
- port_rows = ''.join(
+ port_rows = (
+ ''.join(
f'| {p} | {c} |
'
for p, c in top_ports
- )
- else:
- port_rows = _empty_row(2, 'no data yet')
+ ) if top_ports else _empty_row(2, 'no data yet')
+ )
- # ── banner lookup ──
- lookup_html = ''
- if port_q is not None:
- try:
- port_num = int(port_q)
- if not 0 <= port_num <= 65535:
- raise ValueError
- banner = cfg.get_banner(port_num)
- txt_preview = banner.decode('latin-1', errors='replace')
- txt_safe = html.escape(txt_preview)
- hex_safe = html.escape(banner.hex())
- lookup_html = f"""
-
-
Port {port_num} — {len(banner)} bytes
-
{hex_safe}
-
{txt_safe}
-
"""
- except (ValueError, TypeError):
- lookup_html = ''
+ # ── CPM chart ──
+ cpm_chart = _render_cpm_chart(stats.cpm_history(60))
# ── recent connections ──
recent = stats.recent_connections(50)
- if recent:
- conn_rows = ''.join(
+ conn_rows = (
+ ''.join(
''
f'| {html.escape(e["timestamp"][:19].replace("T", " "))} | '
f'{html.escape(e["src_ip"])} | '
f'{e["src_port"]} | '
f'{e["dst_port"]} | '
- f'{html.escape(e["banner_hex"][:48])}{"…" if len(e["banner_hex"]) > 48 else ""} | '
+ f''
+ f'{html.escape(e["banner_hex"][:48])}{"…" if len(e["banner_hex"]) > 48 else ""} | '
f'{e["banner_len"]} | '
'
'
for e in recent
- )
- else:
- conn_rows = _empty_row(6, 'no connections yet')
+ ) if recent else _empty_row(6, 'no connections yet')
+ )
last = stats.last_connection
last_seen = last[:19].replace('T', ' ') + ' UTC' if last else '—'
@@ -484,10 +501,9 @@ def _render_dashboard(
uptime=stats.uptime_str(),
ports=len(cfg.port_map),
last_seen=html.escape(last_seen),
+ cpm_chart=cpm_chart,
top_ips_rows=ip_rows,
top_ports_rows=port_rows,
- port_q=html.escape(str(port_q)) if port_q is not None else '',
- lookup_html=lookup_html,
recent_count=len(recent),
conn_rows=conn_rows,
)
@@ -614,9 +630,14 @@ async def _handle(
ct = 'application/json'
# ── standard routes ───────────────────────────────────────────────────
+ elif path == '/api/stats/reset' and method == 'POST':
+ stats.reset()
+ writer.write(_redirect('/'))
+ await writer.drain()
+ return
+
elif path == '/':
- port_q = qs.get('port', [None])[0]
- body = _render_dashboard(stats, cfg, port_q)
+ body = _render_dashboard(stats, cfg)
ct = 'text/html; charset=utf-8'
elif path == '/api/stats':
diff --git a/portspoof_py/stats.py b/portspoof_py/stats.py
index 26cd1f6..77f4f8c 100644
--- a/portspoof_py/stats.py
+++ b/portspoof_py/stats.py
@@ -29,9 +29,21 @@ class Stats:
self._last_ts: str | None = None # ISO timestamp of last connection
self._recent: deque = deque(maxlen=max_recent)
self._timestamps: deque = deque() # monotonic timestamps for rolling rate
+ self._minute_buckets: dict = {} # int(wall_time // 60) → connection count
self._top_ips = Counter()
self._top_ports = Counter()
+ def reset(self) -> None:
+ """Clear all counters and restart the uptime clock."""
+ self._start = time.monotonic()
+ self._total = 0
+ self._last_ts = None
+ self._recent.clear()
+ self._timestamps.clear()
+ self._minute_buckets.clear()
+ self._top_ips.clear()
+ self._top_ports.clear()
+
# ── write side (called from logger writer coroutine) ────────────────────
def record(self, event: dict) -> None:
@@ -46,6 +58,9 @@ class Stats:
self._last_ts = event['timestamp']
self._top_ips[event['src_ip']] += 1
self._top_ports[event['dst_port']] += 1
+ # Per-minute bucket for the chart
+ bucket = int(time.time() // 60)
+ self._minute_buckets[bucket] = self._minute_buckets.get(bucket, 0) + 1
# ── read side (called from admin HTTP handler) ───────────────────────────
@@ -73,6 +88,15 @@ class Stats:
def top_ports(self, n: int = 10) -> List[Tuple[int, int]]:
return self._top_ports.most_common(n)
+ def cpm_history(self, n: int = 60) -> List[int]:
+ """Return n per-minute connection counts, oldest first, ending at the current minute."""
+ now_bucket = int(time.time() // 60)
+ # Prune buckets older than our window to keep the dict bounded
+ cutoff = now_bucket - n
+ for k in [k for k in self._minute_buckets if k < cutoff]:
+ del self._minute_buckets[k]
+ return [self._minute_buckets.get(now_bucket - (n - 1 - i), 0) for i in range(n)]
+
@property
def last_connection(self) -> str | None:
return self._last_ts