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"