Files
Nexus-Guard-Suite/.omo/plans/archived/device-status-fix.md
T
datadunia cbacfea7f2
NexusGuard CI / server-core-test (push) Failing after 3m6s
NexusGuard CI / server-core-build (push) Has been skipped
NexusGuard CI / device-agent-test (push) Failing after 4s
NexusGuard CI / device-agent-cross-build (amd64, linux) (push) Has been skipped
NexusGuard CI / device-agent-cross-build (amd64, windows) (push) Has been skipped
NexusGuard CI / device-agent-cross-build (arm64, linux) (push) Has been skipped
NexusGuard CI / dashboard-test (push) Failing after 4s
NexusGuard CI / dashboard-dist (push) Has been skipped
chore: update submodule refs, clean up plans/evidence, update .gitignore
2026-06-07 23:53:15 +07:00

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
  • ClientInfo field 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: IsActive DB field only updated by Redis heartbeat collector (device-agent only)
  • WGDashboard approach: Uses wg showlatest handshake for 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 name field, frontend missing
  • Test strategy: Tests after implementation

Research Findings:

  • SyncToDB() in heartbeat/redis.go is ONLY place IsActive gets set — runs every 30s, checks Redis key TTL (90s)
  • wgmanager_linux.go:27-59 (GetStatus()) returns aggregate data only — per-peer data needed
  • wgctrl returns []wgtypes.Peer with PublicKey, LastHandshakeTime, ReceiveBytes, TransmitBytes
  • WireGuard protocol does NOT send client version info — only detectable via kernel/handshake
  • UpdateDeviceRequest already supports Name field — frontend DeviceDetail.vue has 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 on WgManager interface + Linux implementation
  • New KernelHandshakeCollector background loop (30s interval)
  • ClientInfo field on Device model + auto-population from heartbeat and kernel sync
  • Device name edit input on DeviceDetail.vue
  • Updated online/offline display using computed isOnline with 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 ./... passes
  • npm run build passes

Must Have

  • Kernel handshake → DB sync for Local Primary Node devices
  • IsActive rule: 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 ClientInfo field to Device model

    What to do:

    • Add ClientInfo string field to Device struct in apps/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, add ClientInfo string after DisablePresharedKey
    • apps/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_info field

    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.txt
    

    Commit: YES

    • Message: feat(models): add ClientInfo field to Device
    • Files: apps/server-core/internal/models/models.go
  • 2. Add GetPeerHandshakes() method to WgManager interface + implementations

    What to do:

    • Add new type PeerHandshake struct in apps/server-core/internal/wgmanager/manager.go:
      type PeerHandshake struct {
        PublicKey        string
        LastHandshakeTime time.Time
        RxBytes          int64
        TxBytes          int64
      }
      
    • Add GetPeerHandshakes() ([]PeerHandshake, error) to WgManager interface
    • 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 (check IsZero()) — skip or return as-is
      • Return slice of PeerHandshake
    • Implement in wgmanager_stub.go: Return nil, nil (empty, no error)
    • Update WgStatus to NOT change (keep existing aggregate method)

    Must NOT do:

    • Do NOT modify existing GetStatus() or WgStatus struct
    • 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 struct
    • apps/server-core/internal/wgmanager/wgmanager_linux.go:27-59 - Existing GetStatus() implementation pattern
    • apps/server-core/internal/wgmanager/wgmanager_stub.go - Stub implementation pattern
    • wgctrl: client.Device(name) returns wgtypes.Device with Peers []wgtypes.Peer
    • Each peer has: PublicKey, LastHandshakeTime, ReceiveBytes, TransmitBytes

    Acceptance Criteria:

    • go build -tags dev ./... passes
    • go 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.txt
    

    Commit: 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
  • 3. Create KernelHandshakeCollector sync loop

    What to do:

    • Create new file apps/server-core/internal/wgmanager/handshakesync.go
    • Define HandshakeCollector struct with db *gorm.DB and mgr WgManager
    • Constructor: NewHandshakeCollector(db *gorm.DB, mgr WgManager) *HandshakeCollector
    • Method Start(ctx context.Context, interval time.Duration): spawns goroutine with ticker, each tick calls syncHandshakes(ctx)
    • Method syncHandshakes(ctx):
      1. Get peers: mgr.GetPeerHandshakes(); if error/empty → return
      2. Build publicKey → PeerHandshake map
      3. 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)
      4. For each device:
        • Skip if suspended
        • Match PublicKey in handshake map
        • If found + !IsZero() + time.Since() < 120s: set is_active=true, update LastHandshake
        • 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
      5. Execute per-device db.Model(&device).Updates(updates)

    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() pattern
    • apps/server-core/internal/models/models.go:58-86 - Device model
    • apps/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.txt
    

    Commit: YES

    • Message: feat(wgmanager): add KernelHandshakeCollector for peer status sync
    • Files: apps/server-core/internal/wgmanager/handshakesync.go
  • 4. Wire KernelHandshakeCollector into main.go

    What to do:

    • In apps/server-core/main.go, after wgMgr := 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.txt
    

    Commit: YES

    • Message: feat(main): wire KernelHandshakeCollector on startup
    • Files: apps/server-core/main.go
  • 5. Update heartbeat handler to accept client_type + update DB

    What to do:

    • In apps/server-core/api/heartbeat.go:
      • Add ClientType string to HeartbeatRequest struct
      • In Ping() handler, after RecordPing():
        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
    • Backend changes required to make this work:
      • Add db *gorm.DB field to HeartbeatHandler struct
      • Update NewHeartbeatHandler to accept *gorm.DB parameter
      • Add "gorm.io/gorm" and "git.datadunia.com/nexusguard/nexus-server-core/internal/models" imports
      • In main.go, update NewHeartbeatHandler(hbMgr)NewHeartbeatHandler(hbMgr, db)
    • In apps/device-agent/internal/client/heartbeat.go:
      • In HeartbeatRequest struct, add ClientType string
      • In StartHeartbeat(), add client_type: "nexusguard-agent" to reqBody

    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 handler
    • apps/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.txt
    

    Commit: YES

    • Message: feat(heartbeat): accept client_type, update Device.ClientInfo
    • Files: apps/server-core/api/heartbeat.go, apps/device-agent/internal/client/heartbeat.go
  • 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" to UpdateDeviceRequest struct
      • In Update() handler, add after DisablePresharedKey block (before line ~276):
        if req.ClientInfo != nil {
            updates["client_info"] = *req.ClientInfo
        }
        
    • Frontend: In apps/dashboard-ui/src/api/devices.ts:
      • Add ClientInfo?: string to Device interface
      • Add client_info to mapDevice(): ClientInfo: d.client_info
      • Add client_info?: string to updateDevice() params

    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 build passes
    • 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.txt
    

    Commit: YES

    • Message: feat(ui): add ClientInfo to device API interface
    • Files: apps/dashboard-ui/src/api/devices.ts
  • 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.IsActive directly to computed: Replace device.IsActive in 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'
    • In apps/dashboard-ui/src/views/Dashboard.vue:

      • Replace d.IsActive in onlineCount / offlineCount with same logic
      • Also update the device card visual indicators (lines 41-43)
      • Same isDeviceOnline() helper inline
    • In apps/dashboard-ui/src/views/DeviceDetail.vue:

      • Update line 12 badge: use computed isOnline based on device.IsActive || LastHandshake < 5 min
      • Keep the Connection Status panel as-is (already accurate from kernel)
    • Better approach: Add a shared isDeviceOnline utility function in devices.ts API 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 - Add isDeviceOnline() helper
    • apps/dashboard-ui/src/views/Devices.vue:32-34 - Current IsActive check
    • apps/dashboard-ui/src/views/Dashboard.vue:41-44,73-74 - Current IsActive checks
    • apps/dashboard-ui/src/views/DeviceDetail.vue:12-14 - Current IsActive badge
    • apps/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 build passes

    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.png
    

    Commit: 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 component
    • apps/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 build passes

    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.png
    

    Commit: 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 PasswordHash exposure — add json:"-" tag

    What to do:

    • In apps/server-core/internal/models/models.go:12, change:
      PasswordHash string `gorm:"not null"`
      
      to:
      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 PasswordHash field

    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.txt
    

    Commit: YES

    • Message: fix(models): hide PasswordHash from JSON serialization
    • Files: apps/server-core/internal/models/models.go
  • 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 {
        
    • 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.txt
    

    Commit: YES

    • Message: fix(api): add Preload to Get handler for WgServer and User
    • Files: apps/server-core/api/devices.go
  • 11. Fix DNS cascade — use PeerDefaultDNS instead of wgServer.DNS

    What to do:

    • In apps/server-core/api/peers.go:212, change:
      if wgServer.DNS != "" {
          dns = wgServer.DNS
      }
      
      to:
      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 from wgServer.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 logic
    • apps/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.txt
    

    Commit: YES

    • Message: fix(peers): use PeerDefaultDNS in config DNS cascade
    • Files: apps/server-core/api/peers.go
  • 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, in CreatePeer() 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:
        DNS = 1.1.1.1
        
        to:
        DNS = %s
        
      • Pass configDNS in the fmt.Sprintf args
      • Also ensure the device.DNS field is considered — since this runs BEFORE the device is saved, use the request value directly

    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 generation
    • apps/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.txt
    

    Commit: YES

    • Message: fix(peers): use cascaded DNS in initial config text
    • Files: apps/server-core/api/peers.go

Final Verification Wave

  • F1. Plan Compliance Auditoracle Read 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 Reviewunspecified-high Run go 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 QAunspecified-high Start 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 Checkdeep For 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