38 KiB
Device Status Fix + Client Info + Name Edit
TL;DR
Quick Summary: Fix peer online/offline detection for official WireGuard clients (HP, laptop, desktop) that currently always show "Offline" because they don't send Redis heartbeat. Sync kernel WireGuard handshake data to DB, add ClientInfo auto-detection, and add missing device name edit form.
Deliverables:
- Kernel handshake → DB sync loop for Local Primary Node devices
ClientInfofield on Device model + auto-population- Client-type detection (device-agent vs official WG client)
- Editable device name on DeviceDetail.vue
- Status fix across all dashboard views
Estimated Effort: Medium Parallel Execution: YES - 3 waves Critical Path: T1 → T2 → T3 → T4 → T7
Context
Original Request
User reports peers remain "Offline" even when connected via official WireGuard client (phone, laptop, desktop). Investigation revealed IsActive is only updated via Redis heartbeat, which only device-agent sends. Official WG clients never send heartbeat → always show Offline.
Interview Summary
Key Discussions:
- Root cause:
IsActiveDB field only updated by Redis heartbeat collector (device-agent only) - WGDashboard approach: Uses
wg show→latest handshakefor online detection - Kernel has real data:
wgctrl.GetStatus()returns per-peer handshake times but never synced to DB - Remote nodes: Outside scope - keep Redis heartbeat for them
- Client info: Auto-detect via heartbeat ("NexusGuard Agent"), manual edit for others
- Device name edit: API already supports
namefield, frontend missing - Test strategy: Tests after implementation
Research Findings:
SyncToDB()inheartbeat/redis.gois ONLY placeIsActivegets set — runs every 30s, checks Redis key TTL (90s)wgmanager_linux.go:27-59(GetStatus()) returns aggregate data only — per-peer data neededwgctrlreturns[]wgtypes.PeerwithPublicKey,LastHandshakeTime,ReceiveBytes,TransmitBytes- WireGuard protocol does NOT send client version info — only detectable via kernel/handshake
UpdateDeviceRequestalready supportsNamefield — frontendDeviceDetail.vuehas no name input
Metis Review
Identified Gaps (addressed):
- Race condition: Dual-path (heartbeat + kernel) must follow "online if EITHER shows activity" precedence rule
- Suspended guard: Kernel sync MUST skip suspended devices
- Zero-value handshake:
time.Time{}must be handled (skip, no match) - Local-only constraint: Kernel sync MUST only apply to devices on "Local Primary Node"
- ClientInfo auto-detection: Set "WireGuard Official Client (auto-detected)" for devices detected via kernel sync
- Stub safe:
GetPeerHandshakes()must return empty on non-Linux
Work Objectives
Core Objective
Fix peer online/offline status for official WireGuard clients by syncing kernel handshake data to DB, add client-type detection, and enable device name editing.
Concrete Deliverables
- New
GetPeerHandshakes()method onWgManagerinterface + Linux implementation - New
KernelHandshakeCollectorbackground loop (30s interval) ClientInfofield on Device model + auto-population from heartbeat and kernel sync- Device name edit input on
DeviceDetail.vue - Updated online/offline display using computed
isOnlinewith kernel+heartbeat fallback
Definition of Done
- Official WG client connected to local node → shows Online on dashboard within 30s
- Device-agent connected → shows Online (unchanged behavior, both paths work)
- Suspended device → shows Offline regardless of kernel handshake
- Remote node device → shows Offline (no kernel sync, no heartbeat)
- Device name editable from DeviceDetail.vue
- ClientInfo auto-detected for device-agent and official WG clients
go build -tags dev ./...passesnpm run buildpasses
Must Have
- Kernel handshake → DB sync for Local Primary Node devices
IsActiverule: TRUE if EITHER heartbeat OR kernel handshake recent (<120s)- Device name editing in frontend
- ClientInfo field with auto-population
Must NOT Have (Guardrails)
- Do NOT sync remote node peer status from kernel
- Do NOT track RxBytes/TxBytes per-device in this phase
- Do NOT create separate last_heartbeat/last_handshake DB fields
- Do NOT modify existing heartbeat SyncToDB() logic
- Do NOT extend heartbeat endpoint auth (keep public)
- Do NOT refactor dual collectors into single loop
- Do NOT expose PrivateKey in any new endpoint
Verification Strategy
ZERO HUMAN INTERVENTION - ALL verification is agent-executed.
Test Decision
- Infrastructure exists: YES (Go tests + Playwright)
- Automated tests: Tests-after
- Framework: Go test + bun test (frontend)
QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to .sisyphus/evidence/task-{N}-{scenario}.ext.
Execution Strategy
Parallel Execution Waves
Wave 1 (Foundation - start immediately):
├── T1: Add ClientInfo field to Device model + AutoMigrate
├── T2: Add GetPeerHandshakes() to WgManager interface + impl
├── T9: Fix PasswordHash exposure (json:"-")
Wave 2 (Core logic - after Wave 1):
├── T3: Create KernelHandshakeCollector (sync loop)
├── T4: Wire sync loop into main.go
├── T5: Update heartbeat handler + device-agent for client_type
├── T10: Add Preload("User").Preload("WgServer") to Get handler (after T9)
├── T11: Fix DNS cascade (wgServer.DNS → PeerDefaultDNS)
├── T12: Fix initial config text DNS (not hardcoded)
Wave 3 (Frontend - after Wave 1, can partially parallel with Wave 2):
├── T6: Update Device interface + backend API for client_info
├── T7: Add computed isOnline across all views
├── T8: Add client_info display + device name edit form
Dependency Matrix
- T1: - T3, T5, T6
- T2: - T3
- T9: - T10
- T3: T1, T2 - T4
- T4: T3 - -
- T5: T1 - -
- T10: T9 - -
- T11: - - -
- T12: - - -
- T6: T1 - T7, T8
- T7: T6 - -
- T8: T6 - -
TODOs
-
1. Add
ClientInfofield to Device modelWhat to do:
- Add
ClientInfo stringfield toDevicestruct inapps/server-core/internal/models/models.go - GORM AutoMigrate will pick it up automatically (no migration script needed)
- No json tag change needed (serialized by default)
- This field stores the type of client: "NexusGuard Agent", "WireGuard Official Client (auto-detected)", or custom admin input
Must NOT do:
- Do NOT add separate last_heartbeat/last_handshake fields
- Do NOT change existing field types or json tags
Recommended Agent Profile:
- Category:
quick - Skills:
[]
Parallelization:
- Can Run In Parallel: YES
- Parallel Group: Wave 1 (start immediately)
- Blocks: T3, T5, T6
- Blocked By: None
References:
apps/server-core/internal/models/models.go:58-86- Device struct, addClientInfo stringafterDisablePresharedKeyapps/server-core/api/devices.go:62-68- DeviceResponse struct (no change needed, client_info is not sensitive)
Acceptance Criteria:
go build -tags dev ./...passes- Device API response includes
client_infofield
QA Scenarios:
Scenario: ClientInfo appears in device response Tool: Bash (curl) Preconditions: API server running, admin JWT token available Steps: 1. GET /api/v1/devices with admin token 2. Inspect first device for client_info field Expected Result: client_info field present (may be empty string) Evidence: .sisyphus/evidence/task-1-clientinfo-field.txtCommit: YES
- Message:
feat(models): add ClientInfo field to Device - Files:
apps/server-core/internal/models/models.go
- Add
-
2. Add
GetPeerHandshakes()method to WgManager interface + implementationsWhat to do:
- Add new type
PeerHandshakestruct inapps/server-core/internal/wgmanager/manager.go:type PeerHandshake struct { PublicKey string LastHandshakeTime time.Time RxBytes int64 TxBytes int64 } - Add
GetPeerHandshakes() ([]PeerHandshake, error)toWgManagerinterface - Implement in
wgmanager_linux.go:- Call
client.Device(m.deviceName)via wgctrl - For each peer in
dev.Peers, extract PublicKey, LastHandshakeTime, ReceiveBytes, TransmitBytes - Handle zero-value
LastHandshakeTime(checkIsZero()) — skip or return as-is - Return slice of PeerHandshake
- Call
- Implement in
wgmanager_stub.go: Returnnil, nil(empty, no error) - Update
WgStatusto NOT change (keep existing aggregate method)
Must NOT do:
- Do NOT modify existing
GetStatus()orWgStatusstruct - Do NOT change interface method signatures (only ADD new method)
Recommended Agent Profile:
- Category:
deep - Skills:
[]
Parallelization:
- Can Run In Parallel: YES
- Parallel Group: Wave 1 (start immediately)
- Blocks: T3
- Blocked By: None
References:
apps/server-core/internal/wgmanager/manager.go:10-41- WgManager interface, WgStatus structapps/server-core/internal/wgmanager/wgmanager_linux.go:27-59- Existing GetStatus() implementation patternapps/server-core/internal/wgmanager/wgmanager_stub.go- Stub implementation patternwgctrl:client.Device(name)returnswgtypes.DevicewithPeers []wgtypes.Peer- Each peer has:
PublicKey,LastHandshakeTime,ReceiveBytes,TransmitBytes
Acceptance Criteria:
go build -tags dev ./...passesgo build(without tags) passes (stub compiles)
QA Scenarios:
Scenario: GetPeerHandshakes returns data on Linux Tool: Bash (go test or build) Preconditions: Only testable on Linux Steps: 1. go build -tags dev ./... passes 2. For stub: GetPeerHandshakes() returns nil, nil Expected Result: Both builds pass Evidence: .sisyphus/evidence/task-2-build-pass.txt Scenario: Stub returns empty Tool: Bash (go vet) Preconditions: None Steps: 1. go vet ./... confirms no issues Expected Result: No compile errors Evidence: .sisyphus/evidence/task-2-vet-pass.txtCommit: YES
- Message:
feat(wgmanager): add GetPeerHandshakes() for per-peer kernel data - Files:
apps/server-core/internal/wgmanager/manager.go,wgmanager_linux.go,wgmanager_stub.go
- Add new type
-
3. Create KernelHandshakeCollector sync loop
What to do:
- Create new file
apps/server-core/internal/wgmanager/handshakesync.go - Define
HandshakeCollectorstruct withdb *gorm.DBandmgr WgManager - Constructor:
NewHandshakeCollector(db *gorm.DB, mgr WgManager) *HandshakeCollector - Method
Start(ctx context.Context, interval time.Duration): spawns goroutine with ticker, each tick callssyncHandshakes(ctx) - Method
syncHandshakes(ctx):- Get peers:
mgr.GetPeerHandshakes(); if error/empty → return - Build
publicKey → PeerHandshakemap - Query: find devices ON Local Primary Node only
db.Where("wg_server_id IN (SELECT id FROM wg_servers WHERE name = ?)", "Local Primary Node").Find(&devices) - For each device:
- Skip if suspended
- Match PublicKey in handshake map
- If found +
!IsZero()+time.Since() < 120s: setis_active=true, updateLastHandshake - If found but stale: set
is_active=false - If ClientInfo empty: auto-detect
"WireGuard Official Client (auto-detected)" - If not in kernel peers: set
is_active=false
- Execute per-device
db.Model(&device).Updates(updates)
- Get peers:
Must NOT do:
- Do NOT merge with heartbeat SyncToDB
- Do NOT touch remote node devices
- Do NOT modify RxBytes/TxBytes in this phase
- Do NOT skip suspended guard
Recommended Agent Profile:
- Category:
deep - Skills:
[]
Parallelization:
- Can Run In Parallel: NO (depends on T1, T2)
- Parallel Group: Wave 2
- Blocks: T4
- Blocked By: T1, T2
References:
apps/server-core/internal/heartbeat/redis.go:57-84- SyncToDB() patternapps/server-core/internal/models/models.go:58-86- Device modelapps/server-core/api/servers.go:51- "Local Primary Node" constant
Acceptance Criteria:
go build -tags dev ./...passes- Skips suspended devices
- Only queries devices on "Local Primary Node"
QA Scenarios:
Scenario: Collector compiles Tool: Bash (go build -tags dev ./...) Preconditions: T1, T2 complete Steps: 1. go build -tags dev ./... Expected Result: Build passes Evidence: .sisyphus/evidence/task-3-build.txtCommit: YES
- Message:
feat(wgmanager): add KernelHandshakeCollector for peer status sync - Files:
apps/server-core/internal/wgmanager/handshakesync.go
- Create new file
-
4. Wire KernelHandshakeCollector into main.go
What to do:
- In
apps/server-core/main.go, afterwgMgr := wgmanager.New()(~line 223):handshakeCollector := wgmanager.NewHandshakeCollector(db, wgMgr) handshakeCollector.Start(context.Background(), 30*time.Second) - No conditional needed — stub returns empty, making sync a no-op
Must NOT do:
- Do NOT add Linux build tags
- Do NOT modify existing heartbeat collector
Recommended Agent Profile:
- Category:
quick - Skills:
[]
Parallelization:
- Can Run In Parallel: NO (depends on T3)
- Parallel Group: Wave 2
- Blocks: None
- Blocked By: T3
References:
apps/server-core/main.go:217-224- Existing heartbeat wiring
Acceptance Criteria:
go build -tags dev ./...passes
QA Scenarios:
Scenario: Server compiles with collector Tool: Bash (go build -tags dev ./...) Preconditions: T3 complete Steps: 1. go build -tags dev ./... Expected Result: No errors Evidence: .sisyphus/evidence/task-4-build.txtCommit: YES
- Message:
feat(main): wire KernelHandshakeCollector on startup - Files:
apps/server-core/main.go
- In
-
5. Update heartbeat handler to accept client_type + update DB
What to do:
- In
apps/server-core/api/heartbeat.go:- Add
ClientType stringtoHeartbeatRequeststruct - In
Ping()handler, afterRecordPing():if req.ClientType != "" { // Update Device.ClientInfo directly in DB h.db.Model(&models.Device{}).Where("id = ?", deviceID).Update("client_info", req.ClientType) } - Keep backward compat: client_type is optional, old agents won't send it
- Add
- Backend changes required to make this work:
- Add
db *gorm.DBfield toHeartbeatHandlerstruct - Update
NewHeartbeatHandlerto accept*gorm.DBparameter - Add
"gorm.io/gorm"and"git.datadunia.com/nexusguard/nexus-server-core/internal/models"imports - In
main.go, updateNewHeartbeatHandler(hbMgr)→NewHeartbeatHandler(hbMgr, db)
- Add
- In
apps/device-agent/internal/client/heartbeat.go:- In
HeartbeatRequeststruct, addClientType string - In
StartHeartbeat(), addclient_type: "nexusguard-agent"toreqBody
- In
Must NOT do:
- Do NOT change heartbeat auth (keep public)
- Do NOT require client_type field (backward compat)
Recommended Agent Profile:
- Category:
unspecified-low - Skills:
[]
Parallelization:
- Can Run In Parallel: NO (depends on T1 for ClientInfo field)
- Parallel Group: Wave 2
- Blocks: None
- Blocked By: T1
References:
apps/server-core/api/heartbeat.go:19-43- Current heartbeat handlerapps/server-core/main.go:310- Heartbeat route (public)apps/device-agent/internal/client/heartbeat.go:17-44- Agent heartbeat sender
Acceptance Criteria:
- Old heartbeat without client_type still works (backward compat)
- Heartbeat with client_type updates Device.ClientInfo in DB
QA Scenarios:
Scenario: Backward compat - no client_type Tool: Bash (curl) Preconditions: API running, valid device_id Steps: 1. POST /api/v1/heartbeat {"device_id": "valid-uuid"} Expected Result: 200 OK (no error) Evidence: .sisyphus/evidence/task-5-backward-compat.txt Scenario: client_type updates DB Tool: Bash (curl) Preconditions: API running, valid device_id Steps: 1. POST /api/v1/heartbeat {"device_id": "valid-uuid", "client_type": "nexusguard-agent"} 2. GET /api/v1/devices/valid-uuid (with admin token) Expected Result: client_info field = "nexusguard-agent" Evidence: .sisyphus/evidence/task-5-client-type.txtCommit: YES
- Message:
feat(heartbeat): accept client_type, update Device.ClientInfo - Files:
apps/server-core/api/heartbeat.go,apps/device-agent/internal/client/heartbeat.go
- In
-
6. Update Device interface + API + backend for client_info
What to do:
- Backend: In
apps/server-core/api/devices.go:- Add
ClientInfo *string json:"client_info"toUpdateDeviceRequeststruct - In
Update()handler, add afterDisablePresharedKeyblock (before line ~276):if req.ClientInfo != nil { updates["client_info"] = *req.ClientInfo }
- Add
- Frontend: In
apps/dashboard-ui/src/api/devices.ts:- Add
ClientInfo?: stringtoDeviceinterface - Add
client_infotomapDevice():ClientInfo: d.client_info - Add
client_info?: stringtoupdateDevice()params
- Add
Must NOT do:
- Do NOT change existing field names or types
Recommended Agent Profile:
- Category:
quick - Skills:
[]
Parallelization:
- Can Run In Parallel: NO (depends on T1)
- Parallel Group: Wave 3
- Blocks: T7, T8
- Blocked By: T1
References:
apps/dashboard-ui/src/api/devices.ts:1-102- Device interface and API functions
Acceptance Criteria:
npm run buildpasses- Device interface includes
ClientInfo
QA Scenarios:
Scenario: Build passes Tool: Bash (npm run build) Preconditions: None Steps: 1. cd apps/dashboard-ui && npm run build Expected Result: vue-tsc + vite build passes Evidence: .sisyphus/evidence/task-6-build.txtCommit: YES
- Message:
feat(ui): add ClientInfo to device API interface - Files:
apps/dashboard-ui/src/api/devices.ts
- Backend: In
-
7. Add computed isOnline across all dashboard views
What to do:
-
In
apps/dashboard-ui/src/views/Devices.vue:- Change line 32-34 from using
device.IsActivedirectly to computed: Replacedevice.IsActivein template with a function call Create a helper in script:const isDeviceOnline = (device: Device): boolean => { if (device.IsActive) return true if (device.LastHandshake) { const elapsed = Date.now() - new Date(device.LastHandshake).getTime() return elapsed < 300000 // 5 min } return false } - Or simpler: use the computed inline in template
- Update template:
isDeviceOnline(device) ? 'Online' : 'Offline'
- Change line 32-34 from using
-
In
apps/dashboard-ui/src/views/Dashboard.vue:- Replace
d.IsActiveinonlineCount/offlineCountwith same logic - Also update the device card visual indicators (lines 41-43)
- Same
isDeviceOnline()helper inline
- Replace
-
In
apps/dashboard-ui/src/views/DeviceDetail.vue:- Update line 12 badge: use computed
isOnlinebased ondevice.IsActive || LastHandshake < 5 min - Keep the Connection Status panel as-is (already accurate from kernel)
- Update line 12 badge: use computed
-
Better approach: Add a shared
isDeviceOnlineutility function indevices.tsAPI module so it can be reused across all views:export const isDeviceOnline = (device: Device): boolean => { if (device.IsActive) return true if (device.LastHandshake && device.LastHandshake !== "0001-01-01T00:00:00Z") { return Date.now() - new Date(device.LastHandshake).getTime() < 300000 } return false }
Must NOT do:
- Do NOT change the API response (leave IsActive as-is from backend)
- Do NOT remove the Connection Status panel
Recommended Agent Profile:
- Category:
visual-engineering - Skills:
[]
Parallelization:
- Can Run In Parallel: YES (with T8)
- Parallel Group: Wave 3
- Blocks: None
- Blocked By: T6
References:
apps/dashboard-ui/src/api/devices.ts- AddisDeviceOnline()helperapps/dashboard-ui/src/views/Devices.vue:32-34- Current IsActive checkapps/dashboard-ui/src/views/Dashboard.vue:41-44,73-74- Current IsActive checksapps/dashboard-ui/src/views/DeviceDetail.vue:12-14- Current IsActive badgeapps/dashboard-ui/src/components/LinkedDevices.vue:64-68- Existing LastHandshake computed (pattern to follow)
Acceptance Criteria:
- Official WG client with recent LastHandshake shows Online
- Device with IsActive=true AND recent LastHandshake shows Online (unchanged)
- Device with no connection (both stale) shows Offline
npm run buildpasses
QA Scenarios:
Scenario: Official WG client shows Online via LastHandshake Tool: Playwright Preconditions: Device with IsActive=false but LastHandshake < 5 min Steps: 1. Navigate to /devices 2. Find the device row 3. Check status text Expected Result: Status shows "Online" (green) Evidence: .sisyphus/evidence/task-7-wg-client-online.png Scenario: Dashboard counts correct Tool: Playwright Preconditions: At least one device with IsActive=false but recent LastHandshake Steps: 1. Navigate to /dashboard 2. Read Online count and device cards Expected Result: Online count matches actual online devices Evidence: .sisyphus/evidence/task-7-dashboard-counts.pngCommit: YES
- Message:
fix(ui): add computed isOnline with LastHandshake fallback across views - Files:
apps/dashboard-ui/src/api/devices.ts,apps/dashboard-ui/src/views/Devices.vue,apps/dashboard-ui/src/views/Dashboard.vue,apps/dashboard-ui/src/views/DeviceDetail.vue
-
-
8. Add client_info display + device name edit form
What to do:
-
In
apps/dashboard-ui/src/views/DeviceDetail.vue:A. Device name edit (editable title):
-
Around line 9, replace static
<h1>{{ device.Name }}</h1>with editable input:<div v-if="editingName" class="flex items-center gap-2"> <input v-model="editName" class="bg-black/50 border border-cyan-500/50 rounded px-3 py-1 text-2xl font-bold text-cyan-300 focus:outline-none" /> <button @click="saveName" class="text-xs bg-cyan-600 px-2 py-1 rounded font-bold">Save</button> <button @click="cancelNameEdit" class="text-xs text-gray-400 px-2 py-1">Cancel</button> </div> <div v-else class="flex items-center gap-3"> <h1 class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-blue-500">{{ device.Name }}</h1> <button @click="startNameEdit" class="text-xs text-gray-500 hover:text-cyan-400 transition"> ✏️ </button> </div> -
Add to script setup:
const editingName = ref(false) const editName = ref('') const startNameEdit = () => { editName.value = device.value?.Name || '' editingName.value = true } const cancelNameEdit = () => { editingName.value = false } const saveName = async () => { if (!device.value || !editName.value.trim()) return await updateDevice(device.value.ID, { name: editName.value.trim() }) device.value.Name = editName.value.trim() editingName.value = false }
B. ClientInfo display (admin only, near keys section):
-
After the keys section (~line 48), add:
<div class="pt-2 border-t border-white/5"> <p class="text-xs text-gray-500 uppercase tracking-wider mb-1">Client Info</p> <div v-if="editingClientInfo" class="flex items-center gap-2"> <input v-model="editClientInfo" class="bg-black/50 border border-cyan-500/50 rounded px-2 py-1 text-xs text-gray-300 w-full focus:outline-none" placeholder="e.g. WireGuard iOS 1.2.3" /> <button @click="saveClientInfo" class="text-xs bg-cyan-600 px-2 py-1 rounded font-bold">Save</button> <button @click="cancelClientInfoEdit" class="text-xs text-gray-400 px-2 py-1">Cancel</button> </div> <div v-else class="flex items-center gap-2"> <p class="font-mono text-xs text-gray-300">{{ device.ClientInfo || '-' }}</p> <button @click="startClientInfoEdit" class="text-xs text-gray-500 hover:text-cyan-400 transition"> ✏️ </button> </div> </div> -
Add to script setup:
const editingClientInfo = ref(false) const editClientInfo = ref('') const startClientInfoEdit = () => { editClientInfo.value = device.value?.ClientInfo || '' editingClientInfo.value = true } const cancelClientInfoEdit = () => { editingClientInfo.value = false } const saveClientInfo = async () => { if (!device.value) return await updateDevice(device.value.ID, { client_info: editClientInfo.value }) device.value.ClientInfo = editClientInfo.value editingClientInfo.value = false }
-
Must NOT do:
- Do NOT allow editing name to empty string
- Do NOT expose client_info edit to non-admin users
- Do NOT change existing key display logic
Recommended Agent Profile:
- Category:
visual-engineering - Skills:
[]
Parallelization:
- Can Run In Parallel: YES (with T7)
- Parallel Group: Wave 3
- Blocks: None
- Blocked By: T6
References:
apps/dashboard-ui/src/views/DeviceDetail.vue:1-369- Full componentapps/dashboard-ui/src/api/devices.ts:58-69- updateDevice already supports name, add client_info
Acceptance Criteria:
- Device name is editable via click on title
- ClientInfo is visible and editable for admin
- Changes persist on reload
npm run buildpasses
QA Scenarios:
Scenario: Device name edit Tool: Playwright Preconditions: Admin logged in, viewing DeviceDetail Steps: 1. Click edit (✏️) button next to device name 2. Change name to "Test Renamed Device" 3. Click Save 4. Reload page Expected Result: Device name shows "Test Renamed Device" Evidence: .sisyphus/evidence/task-8-name-edit.png Scenario: ClientInfo edit Tool: Playwright Preconditions: Admin logged in, viewing DeviceDetail Steps: 1. Find ClientInfo section 2. Click edit button 3. Type "WireGuard iOS 3.4.5" 4. Click Save 5. Reload page Expected Result: ClientInfo shows "WireGuard iOS 3.4.5" Evidence: .sisyphus/evidence/task-8-clientinfo-edit.pngCommit: YES
- Message:
feat(ui): add editable device name and client_info fields - Files:
apps/dashboard-ui/src/views/DeviceDetail.vue,apps/dashboard-ui/src/api/devices.ts
-
-
9. Fix
PasswordHashexposure — addjson:"-"tagWhat to do:
- In
apps/server-core/internal/models/models.go:12, change:to:PasswordHash string `gorm:"not null"`PasswordHash string `json:"-" gorm:"not null"` - This prevents PasswordHash from being serialized in ANY API response
- No functional impact: password hash is only used internally for auth comparison and CLI -create-admin
Must NOT do:
- Do NOT change the column name or gorm tag
- Do NOT remove the field — it's still needed for auth
Recommended Agent Profile:
- Category:
quick - Skills:
[]
Parallelization:
- Can Run In Parallel: YES (with any task)
- Parallel Group: Wave 1 (start immediately)
- Blocks: None
- Blocked By: None
References:
apps/server-core/internal/models/models.go:9-16- User struct
Acceptance Criteria:
go build -tags dev ./...passes- GET /api/v1/devices/:id → User object does NOT contain
PasswordHashfield
QA Scenarios:
Scenario: PasswordHash hidden from API response Tool: Bash (curl) Preconditions: API running, admin JWT Steps: 1. GET /api/v1/devices/:id with admin token 2. Inspect JSON response for "PasswordHash" or "password_hash" Expected Result: No password_hash field in User object Evidence: .sisyphus/evidence/task-9-no-passwordhash.txtCommit: YES
- Message:
fix(models): hide PasswordHash from JSON serialization - Files:
apps/server-core/internal/models/models.go
- In
-
10. Add Preload("User").Preload("WgServer") to Get handler
What to do:
- In
apps/server-core/api/devices.go:170-181, update the Get handler:- Change both admin and non-admin queries to include
.Preload("User").Preload("WgServer") - For admin:
if err := h.db.Preload("User").Preload("WgServer").Where("id = ?", id).First(&device).Error; err != nil { - For non-admin:
if err := h.db.Preload("User").Preload("WgServer").Where("id = ? AND user_id = ?", id, userID).First(&device).Error; err != nil {
- Change both admin and non-admin queries to include
- This fixes the issue where WgServer and User return as zero-valued objects in the API response
Must NOT do:
- Do NOT change the filter conditions or response structure
- Do NOT add Preload before verifying T9 (PasswordHash fix) — otherwise PasswordHash would leak
Recommended Agent Profile:
- Category:
quick - Skills:
[]
Parallelization:
- Can Run In Parallel: NO (should be after T9 to avoid PasswordHash leak)
- Parallel Group: Wave 2 (after T9)
- Blocks: None
- Blocked By: T9
References:
apps/server-core/api/devices.go:166-188- Get handler (current, no Preload)apps/server-core/api/devices.go:41- List handler (has Preload, pattern to follow)
Acceptance Criteria:
go build -tags dev ./...passes- GET /api/v1/devices/:id → WgServer object populated with real data (not zero values)
- GET /api/v1/devices/:id → User object populated with real data (without PasswordHash)
QA Scenarios:
Scenario: WgServer populated in Get response Tool: Bash (curl) Preconditions: API running, admin JWT, device exists on a node Steps: 1. GET /api/v1/devices/:id with admin token 2. Inspect WgServer.Name field Expected Result: WgServer.Name is not empty, shows actual node name Evidence: .sisyphus/evidence/task-10-wgserver-populated.txt Scenario: User populated in Get response Tool: Bash (curl) Preconditions: API running, admin JWT, device has owner Steps: 1. GET /api/v1/devices/:id with admin token 2. Inspect User.Username field Expected Result: User.Username is not empty, shows actual username Evidence: .sisyphus/evidence/task-10-user-populated.txtCommit: YES
- Message:
fix(api): add Preload to Get handler for WgServer and User - Files:
apps/server-core/api/devices.go
- In
-
11. Fix DNS cascade — use PeerDefaultDNS instead of wgServer.DNS
What to do:
- In
apps/server-core/api/peers.go:212, change:to:if wgServer.DNS != "" { dns = wgServer.DNS }if wgServer.PeerDefaultDNS != "" { dns = wgServer.PeerDefaultDNS } - This fixes the DNS cascade in
getDeviceConfig()(config download):- device.DNS → highest priority
- wgServer.PeerDefaultDNS → fallback (was incorrectly using wgServer.DNS)
- "1.1.1.1" → hardcoded final fallback
- Note:
wgServer.DNS(node listen DNS) is different fromwgServer.PeerDefaultDNS(default DNS for peers on this node)
Must NOT do:
- Do NOT change the final fallback "1.1.1.1"
- Do NOT change device.DNS priority check
Recommended Agent Profile:
- Category:
quick - Skills:
[]
Parallelization:
- Can Run In Parallel: YES (with any Wave 2 task)
- Parallel Group: Wave 2
- Blocks: None
- Blocked By: None
References:
apps/server-core/api/peers.go:211-217- DNS cascade logicapps/server-core/internal/models/models.go:34- wgServer.DNS (node own DNS)apps/server-core/internal/models/models.go:42- wgServer.PeerDefaultDNS (default peer DNS)
Acceptance Criteria:
go build -tags dev ./...passes- Config download uses peer_default_dns when device.DNS is empty
QA Scenarios:
Scenario: Config download uses PeerDefaultDNS Tool: Bash (curl) Preconditions: API running, device has empty DNS, node has peer_default_dns = "8.8.8.8" Steps: 1. GET /api/v1/devices/:id/config with admin token 2. Inspect "DNS =" line in config Expected Result: DNS = 8.8.8.8 (from PeerDefaultDNS, not 1.1.1.1) Evidence: .sisyphus/evidence/task-11-dns-cascade.txtCommit: YES
- Message:
fix(peers): use PeerDefaultDNS in config DNS cascade - Files:
apps/server-core/api/peers.go
- In
-
12. Fix initial config text to use cascaded DNS, not hardcoded 1.1.1.1
What to do:
- In
apps/server-core/api/peers.go:155-171, inCreatePeer()handler:- Before generating configText, compute the DNS value using same cascade logic:
configDNS := "1.1.1.1" if wgServer.PeerDefaultDNS != "" { configDNS = wgServer.PeerDefaultDNS } if req.DNS != "" { configDNS = req.DNS } - Change line 158 from:
to:
DNS = 1.1.1.1DNS = %s - Pass
configDNSin the fmt.Sprintf args - Also ensure the
device.DNSfield is considered — since this runs BEFORE the device is saved, use the request value directly
- Before generating configText, compute the DNS value using same cascade logic:
Must NOT do:
- Do NOT change the config text format/structure
- Do NOT affect the actual saved device.DNS value (that already uses PeerDefaultDNS via line 110)
Recommended Agent Profile:
- Category:
quick - Skills:
[]
Parallelization:
- Can Run In Parallel: YES (with any Wave 2 task)
- Parallel Group: Wave 2
- Blocks: None
- Blocked By: None
References:
apps/server-core/api/peers.go:155-171- CreatePeer config text generationapps/server-core/api/peers.go:211-217- DNS cascade pattern (same logic)apps/server-core/api/peers.go:110- Device.DNS already uses PeerDefaultDNS
Acceptance Criteria:
go build -tags dev ./...passes- Initial config text shows correct DNS from cascade, not hardcoded 1.1.1.1
QA Scenarios:
Scenario: Initial config uses cascaded DNS Tool: Bash (curl) Preconditions: API running, node has peer_default_dns = "8.8.8.8" Steps: 1. POST /api/v1/peers with new device data (no DNS override) 2. Inspect config_text in response for "DNS =" Expected Result: DNS = 8.8.8.8 (from PeerDefaultDNS) Evidence: .sisyphus/evidence/task-12-initial-config-dns.txtCommit: YES
- Message:
fix(peers): use cascaded DNS in initial config text - Files:
apps/server-core/api/peers.go
- In
Final Verification Wave
-
F1. Plan Compliance Audit —
oracleRead the plan end-to-end. Verify all Must Haves implemented: kernel sync loop, ClientInfo field, name editing, status display fix. Verify all Must NOT Haves absent: no RxBytes tracking, no remote node sync, no heartbeat refactor. Check evidence files exist. Output:Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT -
F2. Code Quality Review —
unspecified-highRungo build -tags dev ./...+npm run build. Check for: unused imports, panic risks in sync loop, zero-value time handling, suspended device guard, race conditions between dual collectors. Output:Build [PASS/FAIL] | Lint [PASS/FAIL] | Files [N clean/N issues] | VERDICT -
F3. Real Manual QA —
unspecified-highStart from clean state. Execute EVERY QA scenario from EVERY task. Test cross-task integration: kernel sync updates DB, frontend reflects changes, ClientInfo persists across reload, name editing works. Output:Scenarios [N/N pass] | Integration [N/N] | VERDICT -
F4. Scope Fidelity Check —
deepFor each task: read "What to do", read actual diff. Verify 1:1 compliance. Check for scope creep (RxBytes tracking, remote node sync, heartbeat refactor). Check suspended device guard is in place. Output:Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT
Commit Strategy
- T1:
feat(models): add ClientInfo field to Device- models.go - T2:
feat(wgmanager): add GetPeerHandshakes() for per-peer kernel data- manager.go, wgmanager_linux.go, wgmanager_stub.go - T9:
fix(models): hide PasswordHash from JSON serialization- models.go - T3:
feat(wgmanager): add KernelHandshakeCollector for peer status sync- handshakesync.go - T4:
feat(main): wire KernelHandshakeCollector on startup- main.go - T5:
feat(heartbeat): accept client_type, update Device.ClientInfo- heartbeat.go, device-agent/heartbeat.go - T10:
fix(api): add Preload to Get handler for WgServer and User- devices.go - T11:
fix(peers): use PeerDefaultDNS in config DNS cascade- peers.go - T12:
fix(peers): use cascaded DNS in initial config text- peers.go - T6:
feat(ui): add ClientInfo to device API interface- devices.ts, devices.go - T7:
fix(ui): add computed isOnline with LastHandshake fallback across views- devices.ts, Devices.vue, Dashboard.vue, DeviceDetail.vue - T8:
feat(ui): add editable device name and client_info fields- DeviceDetail.vue, devices.ts
Success Criteria
Verification Commands
go build -tags dev ./... # Expected: PASS (no errors)
cd apps/dashboard-ui && npm run build # Expected: PASS (vue-tsc + vite)
Final Checklist
isDeviceOnline()utility exported from devices.ts- All 3 dashboard views use computed isOnline instead of raw IsActive
- KernelHandshakeCollector syncs handshake → DB every 30s
- ClientInfo auto-populated from heartbeat ("NexusGuard Agent") and kernel sync ("WireGuard Official Client (auto-detected)")
- Device name editable and persistent
- ClientInfo editable by admin
- All commits pushed to remote