6481a13caf
NexusGuard CI/CD / server-core-test (push) Failing after 33s
NexusGuard CI/CD / server-core-build (push) Has been skipped
NexusGuard CI/CD / device-agent-test (push) Failing after 35s
NexusGuard CI/CD / device-agent-cross-build (amd64, linux) (push) Has been skipped
NexusGuard CI/CD / device-agent-cross-build (amd64, windows) (push) Has been skipped
NexusGuard CI/CD / device-agent-cross-build (arm64, linux) (push) Has been skipped
NexusGuard CI/CD / dashboard-test (push) Failing after 30s
NexusGuard CI/CD / dashboard-dist (push) Has been skipped
NexusGuard CI/CD / release (push) Has been skipped
1005 lines
38 KiB
Markdown
1005 lines
38 KiB
Markdown
# 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 show` → `latest 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
|
|
|
|
- [x] 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`
|
|
|
|
- [x] 2. Add `GetPeerHandshakes()` method to WgManager interface + implementations
|
|
|
|
**What to do**:
|
|
- Add new type `PeerHandshake` struct in `apps/server-core/internal/wgmanager/manager.go`:
|
|
```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`
|
|
|
|
- [x] 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`
|
|
|
|
- [x] 4. Wire KernelHandshakeCollector into main.go
|
|
|
|
**What to do**:
|
|
- In `apps/server-core/main.go`, after `wgMgr := wgmanager.New()` (~line 223):
|
|
```go
|
|
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`
|
|
|
|
- [x] 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()`:
|
|
```go
|
|
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`
|
|
|
|
- [x] 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):
|
|
```go
|
|
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`
|
|
|
|
- [x] 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:
|
|
```ts
|
|
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:
|
|
```ts
|
|
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`
|
|
|
|
- [x] 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:
|
|
```html
|
|
<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:
|
|
```ts
|
|
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:
|
|
```html
|
|
<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:
|
|
```ts
|
|
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`
|
|
|
|
- [x] 9. Fix `PasswordHash` exposure — add `json:"-"` tag
|
|
|
|
**What to do**:
|
|
- In `apps/server-core/internal/models/models.go:12`, change:
|
|
```go
|
|
PasswordHash string `gorm:"not null"`
|
|
```
|
|
to:
|
|
```go
|
|
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`
|
|
|
|
- [x] 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:
|
|
```go
|
|
if err := h.db.Preload("User").Preload("WgServer").Where("id = ?", id).First(&device).Error; err != nil {
|
|
```
|
|
- For non-admin:
|
|
```go
|
|
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`
|
|
|
|
- [x] 11. Fix DNS cascade — use PeerDefaultDNS instead of wgServer.DNS
|
|
|
|
**What to do**:
|
|
- In `apps/server-core/api/peers.go:212`, change:
|
|
```go
|
|
if wgServer.DNS != "" {
|
|
dns = wgServer.DNS
|
|
}
|
|
```
|
|
to:
|
|
```go
|
|
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`
|
|
|
|
- [x] 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:
|
|
```go
|
|
configDNS := "1.1.1.1"
|
|
if wgServer.PeerDefaultDNS != "" {
|
|
configDNS = wgServer.PeerDefaultDNS
|
|
}
|
|
if req.DNS != "" {
|
|
configDNS = req.DNS
|
|
}
|
|
```
|
|
- Change line 158 from:
|
|
```go
|
|
DNS = 1.1.1.1
|
|
```
|
|
to:
|
|
```go
|
|
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
|
|
|
|
- [x] F1. **Plan Compliance Audit** — `oracle`
|
|
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`
|
|
|
|
- [x] F2. **Code Quality Review** — `unspecified-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`
|
|
|
|
- [x] F3. **Real Manual QA** — `unspecified-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`
|
|
|
|
- [x] F4. **Scope Fidelity Check** — `deep`
|
|
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
|
|
```bash
|
|
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
|