feat: live traffic chart, connection status indicators, 5s refresh
Some checks failed
Dev / test (push) Failing after 2m43s
Dev / docker (push) Has been skipped

- 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
This commit is contained in:
Stefano Bertelli 2026-03-31 19:12:33 -05:00
parent c5b66349d6
commit 71a5f57105
4 changed files with 171 additions and 20 deletions

24
TODO.md
View file

@ -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 ✅

View file

@ -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",
'''
<q-td :props="props">
<q-badge :color="props.row.status_color" rounded class="q-mr-sm" />
<span class="text-caption">{{ props.row.status_label }}</span>
</q-td>
''',
)
table.add_slot(
"body-cell-actions",
'''
@ -284,6 +297,11 @@ async def admin_devices_page():
</q-td>
''',
)
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):

View file

@ -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",
'''
<q-td :props="props">
<q-badge :color="props.row.status_color" rounded class="q-mr-sm" />
<span class="text-caption">{{ props.row.status_label }}</span>
</q-td>
''',
)
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"):

View file

@ -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"