From e3da49ec23d170145d02c073e6f6ff4af3c33042 Mon Sep 17 00:00:00 2001 From: nessi Date: Tue, 24 Mar 2026 18:07:46 +0100 Subject: [PATCH] feat: add device connection status and owner username to device listings with handshake-based connectivity detection Add connection_status and owner_username fields to Device model. Join users table in ListByUser and ListAll queries to populate owner_username from username column. Parse latest_handshake_at from gateway runtime snapshots and aggregate across peers. Add isPeerConnected helper with 3-minute handshake timeout threshold. Set connection_status to "connected" or "disconnected" based on handsh --- admin-web/src/api/client.ts | 2 + .../src/features/devices/DevicesPage.tsx | 4 +- backend/internal/device/repository.go | 38 +++++++++++++++---- backend/internal/device/types.go | 20 +++++----- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts index e539f01..9cbd5ec 100644 --- a/admin-web/src/api/client.ts +++ b/admin-web/src/api/client.ts @@ -14,8 +14,10 @@ export type Device = { id: string; name: string; user_id?: string; + owner_username?: string; platform: string; status: string; + connection_status: string; assigned_ip?: string; rx_bytes: number; tx_bytes: number; diff --git a/admin-web/src/features/devices/DevicesPage.tsx b/admin-web/src/features/devices/DevicesPage.tsx index b373f0d..4de597d 100644 --- a/admin-web/src/features/devices/DevicesPage.tsx +++ b/admin-web/src/features/devices/DevicesPage.tsx @@ -7,6 +7,7 @@ import { Table } from "../../components/Table"; const columns = [ { key: "name", label: "Device" }, { key: "owner", label: "Owner" }, + { key: "connection", label: "Connection" }, { key: "platform", label: "Platform" }, { key: "ip", label: "VPN IP" }, { key: "received", label: "Received" }, @@ -54,7 +55,8 @@ export function DevicesPage() { const rows = query.data?.map((device) => ({ id: device.id, name: device.name, - owner: device.user_id ?? "assigned user", + owner: device.owner_username || device.user_id || "unassigned", + connection: device.connection_status === "connected" ? "Connected" : "Disconnected", platform: device.platform, ip: device.assigned_ip ?? "-", received: formatDataSize(device.rx_bytes ?? 0), diff --git a/backend/internal/device/repository.go b/backend/internal/device/repository.go index 851256f..d36eebc 100644 --- a/backend/internal/device/repository.go +++ b/backend/internal/device/repository.go @@ -221,8 +221,9 @@ func (r *PGRepository) SetSelectedProfileID(ctx context.Context, deviceID uuid.U func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) { rows, err := r.db.Query(ctx, ` - select d.id, d.user_id, d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '') + select d.id, d.user_id, coalesce(u.username, ''), d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '') from devices d + left join users u on u.id = d.user_id left join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null where d.user_id = $1 and d.deleted_at is null order by d.created_at desc @@ -235,7 +236,7 @@ func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Devi var items []Device for rows.Next() { var item Device - if err := rows.Scan(&item.ID, &item.UserID, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil { + if err := rows.Scan(&item.ID, &item.UserID, &item.OwnerUsername, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil { return nil, err } items = append(items, item) @@ -248,8 +249,9 @@ func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Devi func (r *PGRepository) ListAll(ctx context.Context) ([]Device, error) { rows, err := r.db.Query(ctx, ` - select d.id, d.user_id, d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '') + select d.id, d.user_id, coalesce(u.username, ''), d.gateway_id, d.name, d.platform, d.status, coalesce(host(wp.assigned_ip), '') from devices d + left join users u on u.id = d.user_id left join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null where d.deleted_at is null order by d.created_at desc @@ -262,7 +264,7 @@ func (r *PGRepository) ListAll(ctx context.Context) ([]Device, error) { var items []Device for rows.Next() { var item Device - if err := rows.Scan(&item.ID, &item.UserID, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil { + if err := rows.Scan(&item.ID, &item.UserID, &item.OwnerUsername, &item.GatewayID, &item.Name, &item.Platform, &item.Status, &item.AssignedIP); err != nil { return nil, err } items = append(items, item) @@ -349,9 +351,10 @@ type runtimeSnapshot struct { } type runtimePeer struct { - DeviceID string `json:"device_id"` - RXBytes uint64 `json:"rx_bytes"` - TXBytes uint64 `json:"tx_bytes"` + DeviceID string `json:"device_id"` + RXBytes uint64 `json:"rx_bytes"` + TXBytes uint64 `json:"tx_bytes"` + LatestHandshakeAt *int64 `json:"latest_handshake_at,omitempty"` } func (r *PGRepository) applyRuntimeStats(ctx context.Context, items []Device) ([]Device, error) { @@ -390,6 +393,11 @@ func (r *PGRepository) applyRuntimeStats(ctx context.Context, items []Device) ([ existing.DeviceID = peer.DeviceID existing.RXBytes += peer.RXBytes existing.TXBytes += peer.TXBytes + if peer.LatestHandshakeAt != nil { + if existing.LatestHandshakeAt == nil || *peer.LatestHandshakeAt > *existing.LatestHandshakeAt { + existing.LatestHandshakeAt = peer.LatestHandshakeAt + } + } statsByDevice[deviceID] = existing } } @@ -401,12 +409,28 @@ func (r *PGRepository) applyRuntimeStats(ctx context.Context, items []Device) ([ if stats, ok := statsByDevice[items[index].ID]; ok { items[index].RXBytes = stats.RXBytes items[index].TXBytes = stats.TXBytes + if isPeerConnected(stats.LatestHandshakeAt) { + items[index].ConnectionStatus = "connected" + } else { + items[index].ConnectionStatus = "disconnected" + } + } else { + items[index].ConnectionStatus = "disconnected" } } return items, nil } +func isPeerConnected(latestHandshakeAt *int64) bool { + if latestHandshakeAt == nil || *latestHandshakeAt <= 0 { + return false + } + + handshakeTime := time.Unix(*latestHandshakeAt, 0) + return time.Since(handshakeTime) <= 3*time.Minute +} + func scanEnrollmentRow(row enrollmentRowScanner) (EnrollmentResponse, error) { var response EnrollmentResponse var profileRevision int diff --git a/backend/internal/device/types.go b/backend/internal/device/types.go index 8e34c95..07d7895 100644 --- a/backend/internal/device/types.go +++ b/backend/internal/device/types.go @@ -12,15 +12,17 @@ type EnrollRequest struct { } type Device struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"user_id,omitempty"` - GatewayID uuid.UUID `json:"gateway_id,omitempty"` - Name string `json:"name"` - Platform string `json:"platform"` - Status string `json:"status"` - AssignedIP string `json:"assigned_ip,omitempty"` - RXBytes uint64 `json:"rx_bytes"` - TXBytes uint64 `json:"tx_bytes"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id,omitempty"` + OwnerUsername string `json:"owner_username,omitempty"` + GatewayID uuid.UUID `json:"gateway_id,omitempty"` + Name string `json:"name"` + Platform string `json:"platform"` + Status string `json:"status"` + ConnectionStatus string `json:"connection_status"` + AssignedIP string `json:"assigned_ip,omitempty"` + RXBytes uint64 `json:"rx_bytes"` + TXBytes uint64 `json:"tx_bytes"` } type ConnectionStatus struct {