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
This commit is contained in:
@@ -14,8 +14,10 @@ export type Device = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
|
owner_username?: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
connection_status: string;
|
||||||
assigned_ip?: string;
|
assigned_ip?: string;
|
||||||
rx_bytes: number;
|
rx_bytes: number;
|
||||||
tx_bytes: number;
|
tx_bytes: number;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Table } from "../../components/Table";
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ key: "name", label: "Device" },
|
{ key: "name", label: "Device" },
|
||||||
{ key: "owner", label: "Owner" },
|
{ key: "owner", label: "Owner" },
|
||||||
|
{ key: "connection", label: "Connection" },
|
||||||
{ key: "platform", label: "Platform" },
|
{ key: "platform", label: "Platform" },
|
||||||
{ key: "ip", label: "VPN IP" },
|
{ key: "ip", label: "VPN IP" },
|
||||||
{ key: "received", label: "Received" },
|
{ key: "received", label: "Received" },
|
||||||
@@ -54,7 +55,8 @@ export function DevicesPage() {
|
|||||||
const rows = query.data?.map((device) => ({
|
const rows = query.data?.map((device) => ({
|
||||||
id: device.id,
|
id: device.id,
|
||||||
name: device.name,
|
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,
|
platform: device.platform,
|
||||||
ip: device.assigned_ip ?? "-",
|
ip: device.assigned_ip ?? "-",
|
||||||
received: formatDataSize(device.rx_bytes ?? 0),
|
received: formatDataSize(device.rx_bytes ?? 0),
|
||||||
|
|||||||
@@ -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) {
|
func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Device, error) {
|
||||||
rows, err := r.db.Query(ctx, `
|
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
|
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
|
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
|
where d.user_id = $1 and d.deleted_at is null
|
||||||
order by d.created_at desc
|
order by d.created_at desc
|
||||||
@@ -235,7 +236,7 @@ func (r *PGRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Devi
|
|||||||
var items []Device
|
var items []Device
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item Device
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
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) {
|
func (r *PGRepository) ListAll(ctx context.Context) ([]Device, error) {
|
||||||
rows, err := r.db.Query(ctx, `
|
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
|
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
|
left join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
|
||||||
where d.deleted_at is null
|
where d.deleted_at is null
|
||||||
order by d.created_at desc
|
order by d.created_at desc
|
||||||
@@ -262,7 +264,7 @@ func (r *PGRepository) ListAll(ctx context.Context) ([]Device, error) {
|
|||||||
var items []Device
|
var items []Device
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item Device
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
@@ -349,9 +351,10 @@ type runtimeSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type runtimePeer struct {
|
type runtimePeer struct {
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
RXBytes uint64 `json:"rx_bytes"`
|
RXBytes uint64 `json:"rx_bytes"`
|
||||||
TXBytes uint64 `json:"tx_bytes"`
|
TXBytes uint64 `json:"tx_bytes"`
|
||||||
|
LatestHandshakeAt *int64 `json:"latest_handshake_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *PGRepository) applyRuntimeStats(ctx context.Context, items []Device) ([]Device, error) {
|
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.DeviceID = peer.DeviceID
|
||||||
existing.RXBytes += peer.RXBytes
|
existing.RXBytes += peer.RXBytes
|
||||||
existing.TXBytes += peer.TXBytes
|
existing.TXBytes += peer.TXBytes
|
||||||
|
if peer.LatestHandshakeAt != nil {
|
||||||
|
if existing.LatestHandshakeAt == nil || *peer.LatestHandshakeAt > *existing.LatestHandshakeAt {
|
||||||
|
existing.LatestHandshakeAt = peer.LatestHandshakeAt
|
||||||
|
}
|
||||||
|
}
|
||||||
statsByDevice[deviceID] = existing
|
statsByDevice[deviceID] = existing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,12 +409,28 @@ func (r *PGRepository) applyRuntimeStats(ctx context.Context, items []Device) ([
|
|||||||
if stats, ok := statsByDevice[items[index].ID]; ok {
|
if stats, ok := statsByDevice[items[index].ID]; ok {
|
||||||
items[index].RXBytes = stats.RXBytes
|
items[index].RXBytes = stats.RXBytes
|
||||||
items[index].TXBytes = stats.TXBytes
|
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
|
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) {
|
func scanEnrollmentRow(row enrollmentRowScanner) (EnrollmentResponse, error) {
|
||||||
var response EnrollmentResponse
|
var response EnrollmentResponse
|
||||||
var profileRevision int
|
var profileRevision int
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ type EnrollRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Device struct {
|
type Device struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
UserID uuid.UUID `json:"user_id,omitempty"`
|
UserID uuid.UUID `json:"user_id,omitempty"`
|
||||||
GatewayID uuid.UUID `json:"gateway_id,omitempty"`
|
OwnerUsername string `json:"owner_username,omitempty"`
|
||||||
Name string `json:"name"`
|
GatewayID uuid.UUID `json:"gateway_id,omitempty"`
|
||||||
Platform string `json:"platform"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Platform string `json:"platform"`
|
||||||
AssignedIP string `json:"assigned_ip,omitempty"`
|
Status string `json:"status"`
|
||||||
RXBytes uint64 `json:"rx_bytes"`
|
ConnectionStatus string `json:"connection_status"`
|
||||||
TXBytes uint64 `json:"tx_bytes"`
|
AssignedIP string `json:"assigned_ip,omitempty"`
|
||||||
|
RXBytes uint64 `json:"rx_bytes"`
|
||||||
|
TXBytes uint64 `json:"tx_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConnectionStatus struct {
|
type ConnectionStatus struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user