From 71a5f5710502c5b6c33fc764d25aa8f7c345af33 Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Tue, 31 Mar 2026 19:12:33 -0500 Subject: [PATCH] feat: live traffic chart, connection status indicators, 5s refresh - Add ECharts live traffic rate chart on device detail page (RX/s + TX/s area lines, 60-point rolling window, human-readable byte axis) - Add traffic rate display (B/s) next to RX/TX totals - Add connection status column (green/yellow/red dot) to user and admin device tables based on handshake age - Add status badge to device detail page - Reduce all UI refresh timers from 30s to 5s - Add row click navigation on admin devices table - Allow admins to view any device detail (not just their own) - Fix rowClick event args (list not dict) on both device tables - Add connection_status() helper in utils/time.py --- TODO.md | 24 +++--- wiregui/pages/admin/devices.py | 21 +++++- wiregui/pages/devices.py | 129 +++++++++++++++++++++++++++++++-- wiregui/utils/time.py | 17 +++++ 4 files changed, 171 insertions(+), 20 deletions(-) diff --git a/TODO.md b/TODO.md index 88cf500..f4afdca 100644 --- a/TODO.md +++ b/TODO.md @@ -38,24 +38,26 @@ Separate Python process dedicated to high-frequency WireGuard stats collection, - [x] Web app spawns collector as subprocess when `WG_METRICS_ENABLED=true` - [x] Web app terminates collector on shutdown -### Phase 3: VictoriaMetrics metrics +### Phase 3: VictoriaMetrics metrics ✅ -Metrics to push (Prometheus exposition format): -- [ ] `wiregui_peer_rx_bytes{public_key, user_email, device_name}` — counter -- [ ] `wiregui_peer_tx_bytes{public_key, user_email, device_name}` — counter -- [ ] `wiregui_peer_latest_handshake_seconds{public_key, user_email, device_name}` — gauge -- [ ] `wiregui_peer_connected{public_key, user_email, device_name}` — 1 if handshake < 180s, else 0 -- [ ] `wiregui_peers_total` — gauge, count of active peers +All metrics implemented in `collector.py` and verified by integration tests: +- [x] `wiregui_peer_rx_bytes{public_key, user_email, device_name}` — counter +- [x] `wiregui_peer_tx_bytes{public_key, user_email, device_name}` — counter +- [x] `wiregui_peer_latest_handshake_seconds{public_key, user_email, device_name}` — gauge +- [x] `wiregui_peer_connected{public_key, user_email, device_name}` — 1 if handshake < 180s, else 0 +- [x] `wiregui_peers_total` — gauge, count of active peers ### Phase 4: UI improvements -- [ ] Reduce UI timer from 30s to 10s on device pages (devices.py, admin/devices.py) -- [ ] Add connection status indicator (green/yellow/red dot) based on handshake age +- [x] Reduce UI timer from 30s to 5s on all device pages (devices.py, admin/devices.py, detail page) +- [x] Add connection status indicator (green/yellow/red dot) based on handshake age - Green: handshake < 2 min - Yellow: handshake < 5 min - Red: no recent handshake or never connected -- [ ] Add traffic rate display (bytes/sec computed from delta between polls) -- [ ] Device detail page: mini traffic chart (query VictoriaMetrics if available, else show last-known values) +- [x] Status column in both user and admin device tables +- [x] Status badge on device detail page (live-updating) +- [x] Add traffic rate display (RX/s, TX/s computed from delta between 5s polls) +- [x] Device detail page: live ECharts traffic rate chart (RX/s + TX/s area lines, 60-point rolling window, auto-scaled axis with human-readable byte formatting) ### Phase 5: Infrastructure ✅ diff --git a/wiregui/pages/admin/devices.py b/wiregui/pages/admin/devices.py index 499113c..34a1d1c 100644 --- a/wiregui/pages/admin/devices.py +++ b/wiregui/pages/admin/devices.py @@ -63,6 +63,7 @@ async def admin_devices_page(): } async def load_devices(user_filter: str | None = None) -> list[dict]: + from wiregui.utils.time import connection_status async with async_session() as session: stmt = select(Device).order_by(Device.inserted_at.desc()) if user_filter and user_filter != "all": @@ -73,6 +74,8 @@ async def admin_devices_page(): "id": str(d.id), "name": d.name, "user": user_map.get(str(d.user_id), "Unknown"), + "status_color": connection_status(d.latest_handshake)[0], + "status_label": connection_status(d.latest_handshake)[1], "ipv4": d.ipv4 or "-", "ipv6": d.ipv6 or "-", "public_key": d.public_key[:16] + "...", @@ -262,6 +265,7 @@ async def admin_devices_page(): ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary") columns = [ + {"name": "status", "label": "", "field": "status_label", "align": "center"}, {"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True}, {"name": "user", "label": "User", "field": "user", "align": "left", "sortable": True}, {"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"}, @@ -273,6 +277,15 @@ async def admin_devices_page(): {"name": "actions", "label": "", "field": "id", "align": "center"}, ] table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full") + table.add_slot( + "body-cell-status", + ''' + + + {{ props.row.status_label }} + + ''', + ) table.add_slot( "body-cell-actions", ''' @@ -284,6 +297,11 @@ async def admin_devices_page(): ''', ) + def on_admin_row_click(e): + # Quasar rowClick args: [evt, row, index] or just row depending on NiceGUI version + row = e.args[1] if isinstance(e.args, list) else e.args + ui.navigate.to(f"/devices/{row['id']}") + table.on("rowClick", on_admin_row_click) table.on("edit", lambda e: open_edit(e.args)) table.on("delete", lambda e: delete_device(e.args)) @@ -356,8 +374,7 @@ async def admin_devices_page(): await refresh_table() - # Auto-refresh stats every 30 seconds - ui.timer(30, refresh_table) + ui.timer(5, refresh_table) def _show_config_dialog(device_name: str, config_text: str): diff --git a/wiregui/pages/devices.py b/wiregui/pages/devices.py index cd142d7..f9d7d93 100644 --- a/wiregui/pages/devices.py +++ b/wiregui/pages/devices.py @@ -60,12 +60,15 @@ async def devices_page(): return list(result.scalars().all()) async def refresh_table(): + from wiregui.utils.time import connection_status devices = await load_devices() table.rows = [ { "id": str(d.id), "name": d.name, "description": d.description or "", + "status_color": connection_status(d.latest_handshake)[0], + "status_label": connection_status(d.latest_handshake)[1], "ipv4": d.ipv4 or "-", "ipv6": d.ipv6 or "-", "public_key": d.public_key[:16] + "...", @@ -173,7 +176,8 @@ async def devices_page(): await refresh_table() def on_row_click(e): - ui.navigate.to(f"/devices/{e.args['id']}") + row = e.args[1] if isinstance(e.args, list) else e.args + ui.navigate.to(f"/devices/{row['id']}") # --- Page content --- with ui.column().classes("w-full p-4"): @@ -182,6 +186,7 @@ async def devices_page(): ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary") columns = [ + {"name": "status", "label": "", "field": "status_label", "align": "center"}, {"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True}, {"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"}, {"name": "ipv6", "label": "IPv6", "field": "ipv6", "align": "left"}, @@ -193,6 +198,15 @@ async def devices_page(): ] table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full") table.on("rowClick", on_row_click) + table.add_slot( + "body-cell-status", + ''' + + + {{ props.row.status_label }} + + ''', + ) table.add_slot( "body-cell-actions", ''' @@ -248,8 +262,7 @@ async def devices_page(): await refresh_table() - # Auto-refresh stats every 30 seconds - ui.timer(30, refresh_table) + ui.timer(5, refresh_table) @ui.page("/devices/{device_id}") @@ -260,9 +273,10 @@ async def device_detail_page(device_id: str): layout() user_id = UUID(app.storage.user["user_id"]) + role = app.storage.user.get("role", "") async with async_session() as sess: device = await sess.get(Device, UUID(device_id)) - if not device or device.user_id != user_id: + if not device or (device.user_id != user_id and role != "admin"): ui.label("Device not found").classes("text-h5 text-negative p-4") return @@ -341,32 +355,133 @@ async def device_detail_page(device_id: str): # Traffic stats (live-updating) with ui.card().classes("w-full q-mt-md"): ui.label("Traffic Stats").classes("text-subtitle1 text-bold") - ui.label("Auto-refreshes every 30s").classes("text-caption text-grey") ui.separator() - with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): + from wiregui.utils.time import connection_status + _color, _label = connection_status(device.latest_handshake) + with ui.row().classes("items-center gap-2 q-pa-sm"): + stat_badge = ui.badge("", color=_color).props("rounded") + stat_status = ui.label(_label).classes("text-caption") + + with ui.grid(columns=3).classes("w-full gap-2 q-pa-sm"): ui.label("RX:").classes("text-bold") stat_rx = ui.label(_format_bytes(device.rx_bytes)) + stat_rx_rate = ui.label("").classes("text-caption text-grey") ui.label("TX:").classes("text-bold") stat_tx = ui.label(_format_bytes(device.tx_bytes)) + stat_tx_rate = ui.label("").classes("text-caption text-grey") ui.label("Last Handshake:").classes("text-bold") stat_handshake = ui.label(str(device.latest_handshake)[:19] if device.latest_handshake else "-") + ui.label("") # spacer ui.label("Remote IP:").classes("text-bold") stat_remote = ui.label(device.remote_ip or "-") + ui.label("") # spacer + + # Traffic chart + MAX_CHART_POINTS = 60 + _chart_times: list[str] = [] + _chart_rx: list[float] = [] + _chart_tx: list[float] = [] + + with ui.card().classes("w-full q-mt-md"): + ui.label("Traffic Rate").classes("text-subtitle1 text-bold") + ui.separator() + traffic_chart = ui.echart({ + "tooltip": { + "trigger": "axis", + ":valueFormatter": """(v) => { + if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'; + if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'; + return v.toFixed(0) + ' B/s'; + }""", + }, + "legend": {"data": ["RX/s", "TX/s"], "right": 20, "top": 5}, + "xAxis": {"type": "category", "data": [], "boundaryGap": False}, + "yAxis": { + "type": "value", + "axisLabel": { + ":formatter": """(v) => { + if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'; + if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'; + if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'; + return v.toFixed(0) + ' B/s'; + }""", + }, + }, + "series": [ + { + "name": "RX/s", + "type": "line", + "smooth": True, + "symbol": "none", + "areaStyle": {"opacity": 0.15}, + "lineStyle": {"width": 2}, + "itemStyle": {"color": "#3598C3"}, + "data": [], + }, + { + "name": "TX/s", + "type": "line", + "smooth": True, + "symbol": "none", + "areaStyle": {"opacity": 0.15}, + "lineStyle": {"width": 2}, + "itemStyle": {"color": "#5AA6B9"}, + "data": [], + }, + ], + "grid": {"left": 60, "right": 20, "top": 40, "bottom": 30}, + }).classes("w-full").style("height: 250px") + + _prev_rx = device.rx_bytes or 0 + _prev_tx = device.tx_bytes or 0 + _prev = {"rx": _prev_rx, "tx": _prev_tx} async def refresh_stats(): + from wiregui.utils.time import connection_status + from datetime import datetime async with async_session() as session: d = await session.get(Device, UUID(device_id)) if not d: return + + # Compute rates + cur_rx = d.rx_bytes or 0 + cur_tx = d.tx_bytes or 0 + rx_rate = max(0, (cur_rx - _prev["rx"]) / 5) + tx_rate = max(0, (cur_tx - _prev["tx"]) / 5) + _prev["rx"] = cur_rx + _prev["tx"] = cur_tx + + # Update labels stat_rx.text = _format_bytes(d.rx_bytes) stat_tx.text = _format_bytes(d.tx_bytes) + stat_rx_rate.text = f"({_format_bytes(int(rx_rate))}/s)" + stat_tx_rate.text = f"({_format_bytes(int(tx_rate))}/s)" stat_handshake.text = str(d.latest_handshake)[:19] if d.latest_handshake else "-" stat_remote.text = d.remote_ip or "-" + color, label = connection_status(d.latest_handshake) + stat_badge.props(f'color={color}') + stat_status.text = label - ui.timer(30, refresh_stats) + # Update chart + now = datetime.now().strftime("%H:%M:%S") + _chart_times.append(now) + _chart_rx.append(round(rx_rate, 1)) + _chart_tx.append(round(tx_rate, 1)) + if len(_chart_times) > MAX_CHART_POINTS: + _chart_times.pop(0) + _chart_rx.pop(0) + _chart_tx.pop(0) + + traffic_chart.options["xAxis"]["data"] = _chart_times + traffic_chart.options["series"][0]["data"] = _chart_rx + traffic_chart.options["series"][1]["data"] = _chart_tx + traffic_chart.update() + + ui.timer(5, refresh_stats) # Active configuration with ui.card().classes("w-full q-mt-md"): diff --git a/wiregui/utils/time.py b/wiregui/utils/time.py index 9efe8ba..3b06a27 100644 --- a/wiregui/utils/time.py +++ b/wiregui/utils/time.py @@ -4,3 +4,20 @@ from datetime import UTC, datetime def utcnow() -> datetime: """Return current UTC time as a naive datetime (for Postgres TIMESTAMP WITHOUT TIME ZONE).""" return datetime.now(UTC).replace(tzinfo=None) + + +def connection_status(latest_handshake: datetime | None) -> tuple[str, str]: + """Return (color, label) based on handshake age. + + Green: handshake < 2 min + Yellow: handshake < 5 min + Red: no recent handshake or never connected + """ + if latest_handshake is None: + return "red", "offline" + age = (utcnow() - latest_handshake).total_seconds() + if age < 120: + return "green", "online" + if age < 300: + return "yellow", "idle" + return "red", "offline"