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
This commit is contained in:
parent
c5b66349d6
commit
71a5f57105
4 changed files with 171 additions and 20 deletions
24
TODO.md
24
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 ✅
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue