diff --git a/Hub/internal/api/sensors.go b/Hub/internal/api/sensors.go index 90f2905..4fa9611 100644 --- a/Hub/internal/api/sensors.go +++ b/Hub/internal/api/sensors.go @@ -118,31 +118,6 @@ func (h *Handler) ReceiveOffline(w http.ResponseWriter, r *http.Request) { SendJSON(w, http.StatusOK, map[string]string{"status": "offline_acknowledged"}) } -func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) { - timeframe := r.URL.Query().Get("timeframe") - if timeframe == "" { - timeframe = "24H" - } - - now := time.Now().UTC() - params := CalculateUptimeParams(timeframe, now) - - sensors, err := h.Store.GetSensorsForUptime(now.Format(time.RFC3339)) - if err != nil { - RespondError(w, "Database error fetching sensors", http.StatusInternalServerError) - return - } - - hbs, err := h.Store.GetHeartbeatsSince(params.CutoffStr) - if err != nil { - RespondError(w, "Database error fetching heartbeats", http.StatusInternalServerError) - return - } - - result := GenerateUptimeResult(timeframe, now, params, sensors, hbs) - SendJSON(w, http.StatusOK, result) -} - func (h *Handler) ToggleSilence(w http.ResponseWriter, r *http.Request) { // Extract both IDs from the updated URL route nodeID := chi.URLParam(r, "id") diff --git a/Hub/internal/api/uptime.go b/Hub/internal/api/uptime.go deleted file mode 100644 index 5f0c781..0000000 --- a/Hub/internal/api/uptime.go +++ /dev/null @@ -1,168 +0,0 @@ -package api - -import ( - "fmt" - "time" - - "github.com/honeywire/hub/internal/store" -) - -// UptimeParams holds the parameters needed to fetch data from the database -type UptimeParams struct { - NumBlocks int - Delta time.Duration - ExpectedPings float64 - Cutoff time.Time - CutoffStr string -} - -func CalculateUptimeParams(timeframe string, now time.Time) UptimeParams { - var numBlocks int - var delta time.Duration - var expectedPings float64 - - switch timeframe { - case "1H": - numBlocks, delta, expectedPings = 30, 2*time.Minute, 2 - case "7D": - numBlocks, delta, expectedPings = 7, 24*time.Hour, 1440 - case "30D": - numBlocks, delta, expectedPings = 30, 24*time.Hour, 1440 - case "24H": - fallthrough - default: - numBlocks, delta, expectedPings = 24, time.Hour, 60 - } - - cutoff := now.Add(-delta * time.Duration(numBlocks)).Truncate(time.Minute) - return UptimeParams{ - NumBlocks: numBlocks, - Delta: delta, - ExpectedPings: expectedPings, - Cutoff: cutoff, - CutoffStr: cutoff.Format(time.RFC3339), - } -} - -func GenerateUptimeResult(timeframe string, now time.Time, params UptimeParams, sensors []store.SensorUptimeData, hbs []store.HeartbeatData) []map[string]interface{} { - history := make(map[string][]float64) - for _, s := range sensors { - historyKey := s.NodeID + ":" + s.SensorID - history[historyKey] = make([]float64, params.NumBlocks) - } - - for _, hb := range hbs { - parsedBucket, err := time.Parse(time.RFC3339, hb.TimeBucket) - if err != nil { - continue - } - - if parsedBucket.Before(params.Cutoff) { - continue - } - - idx := int(parsedBucket.Sub(params.Cutoff) / params.Delta) - if idx >= params.NumBlocks { - idx = params.NumBlocks - 1 - } - - historyKey := hb.NodeID + ":" + hb.SensorID - if idx >= 0 && history[historyKey] != nil { - history[historyKey][idx]++ - } - } - - var result []map[string]interface{} - for _, s := range sensors { - firstSeenParsed, _ := time.Parse(time.RFC3339, s.FirstSeen) - var blocks []map[string]string - - historyKey := s.NodeID + ":" + s.SensorID - - for i := 0; i < params.NumBlocks; i++ { - blockStart := params.Cutoff.Add(time.Duration(i) * params.Delta) - blockEnd := blockStart.Add(params.Delta) - - stepsAgo := params.NumBlocks - 1 - i - timeLabel := "Current" - if stepsAgo > 0 { - switch timeframe { - case "1H": - timeLabel = fmt.Sprintf("%d mins ago", stepsAgo*int(params.Delta.Minutes())) - case "24H": - timeLabel = fmt.Sprintf("%d hours ago", stepsAgo) - case "7D", "30D": - timeLabel = fmt.Sprintf("%d days ago", stepsAgo) - default: - timeLabel = fmt.Sprintf("%d ago", stepsAgo) - } - } - - status, label := "", "" - - if blockEnd.Before(firstSeenParsed) { - status, label = "nodata", "No Data (Not Deployed Yet)" - } else { - pings := history[historyKey][i] - targetPings := params.ExpectedPings - - if firstSeenParsed.After(blockStart) && firstSeenParsed.Before(blockEnd) { - activeDuration := blockEnd.Sub(firstSeenParsed) - targetPings = activeDuration.Minutes() - if targetPings > params.ExpectedPings { - targetPings = params.ExpectedPings - } - if targetPings < 1 && activeDuration > 0 { - targetPings = 1 - } - } else if i == params.NumBlocks-1 { - activeDuration := now.Sub(blockStart) - targetPings = activeDuration.Minutes() - if targetPings > params.ExpectedPings { - targetPings = params.ExpectedPings - } - if targetPings < 1 && activeDuration > 0 { - targetPings = 1 - } - } - - if pings == 0 && targetPings >= 1 { - status, label = "down", "Offline" - } else if targetPings > 0 && pings < (targetPings*0.85) { - status, label = "degraded", fmt.Sprintf("Degraded (%.0f/%.0f pings)", pings, targetPings) - } else { - status, label = "up", "Online" - } - } - - blocks = append(blocks, map[string]string{ - "status": status, - "timeLabel": timeLabel, - "label": label, - }) - } - - isLive := now.Sub(s.LastSeen) < 60*time.Second - if isLive { - blocks[len(blocks)-1]["status"] = "up" - blocks[len(blocks)-1]["label"] = "Online (Live)" - } else { - blocks[len(blocks)-1]["status"] = "down" - blocks[len(blocks)-1]["label"] = "Offline (Live)" - } - - result = append(result, map[string]interface{}{ - "id": s.SensorID, - "node_id": s.NodeID, - "name": s.SensorID, - "isOnline": isLive, - "blocks": blocks, - }) - } - - if result == nil { - result = []map[string]interface{}{} - } - - return result -} diff --git a/Hub/internal/api/uptime_handler.go b/Hub/internal/api/uptime_handler.go new file mode 100644 index 0000000..e699060 --- /dev/null +++ b/Hub/internal/api/uptime_handler.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + "time" + + "github.com/honeywire/hub/internal/projections/uptime" +) + +// GetUptime handles GET /api/v1/uptime and returns the fleet uptime projection +func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) { + // Parse timeframe from query string (default to 24H) + timeframe := r.URL.Query().Get("timeframe") + if timeframe == "" { + timeframe = "24H" + } + + // Validate timeframe + validTimeframes := map[string]bool{ + "1H": true, + "24H": true, + "7D": true, + "30D": true, + } + if !validTimeframes[timeframe] { + RespondError(w, "Invalid timeframe. Valid values: 1H, 24H, 7D, 30D", http.StatusBadRequest) + return + } + + // Create projector + projector := uptime.NewProjector(h.Store) + + // Build projection + criteria := uptime.FilterCriteria{ + Timeframe: timeframe, + Now: time.Now().UTC(), + } + + projection, err := projector.BuildUptimeProjection(criteria) + if err != nil { + RespondError(w, "Failed to build uptime projection", http.StatusInternalServerError) + return + } + + // Return the projection as JSON + SendJSON(w, http.StatusOK, projection) +} diff --git a/Hub/internal/projections/uptime/UPTIME_ARCHITECTURE.md b/Hub/internal/projections/uptime/UPTIME_ARCHITECTURE.md new file mode 100644 index 0000000..e470d0c --- /dev/null +++ b/Hub/internal/projections/uptime/UPTIME_ARCHITECTURE.md @@ -0,0 +1,364 @@ +# Uptime Projection Architecture Guide + +This document describes the refactored uptime analytics architecture, which moves from frontend-computed UI to backend-generated projections. + +## Architecture Overview + +### Backend Projection Model +``` +Database → Backend Calculations → Typed DTOs → Frontend Strict Rendering +``` + +**Benefits:** +- Strong type contracts between backend and frontend +- Single source of truth for all uptime logic +- Pure business logic separated from HTTP handlers +- Testable calculations with zero external dependencies +- Frontend only does shallow hydration for real-time updates + +## Directory Structure + +``` +internal/ +├── projections/ # New domain: analytics/UI projection layer +│ └── uptime/ +│ ├── dto.go # API contracts (zero logic) +│ ├── calculator.go # Pure business logic +│ └── projection.go # Orchestration & mapping +├── api/ +│ ├── uptime_handler.go # Thin HTTP handler +│ └── ... (other handlers) +└── ... (other packages) +``` + +## Component Responsibilities + +### 1. DTOs (`dto.go`) + +**Purpose:** Define the exact JSON structure the frontend expects. + +**Rules:** +- Zero business logic or methods +- Explicit JSON tags on all fields +- Immutable once defined (breaking changes require versioning) + +**Types:** +- `UptimeResponse`: Root response object +- `UptimeSummary`: Fleet-wide statistics +- `UptimeGroup`: Sensors grouped by node +- `UptimeSensor`: Individual sensor with blocks +- `UptimeBlock`: Single time-bucket heatmap cell + +### 2. Calculator (`calculator.go`) + +**Purpose:** Pure business logic isolated from HTTP/database concerns. + +**Rules:** +- No database access (data passed as parameters) +- No HTTP request context +- Functions should be deterministic and testable +- Descriptive names that explain intent + +**Key Functions:** +- `CalculateParams()`: Determine block count, delta, expected pings per timeframe +- `BuildHeartbeatHistory()`: Aggregate heartbeats into time-bucketed map +- `CalculateBlockStatus()`: Determine up/down/degraded for a single block +- `GenerateBlocks()`: Build heatmap for a sensor +- `ResolveWorstStatus()`: Determine worst status from list (down > degraded > up) +- `CalculateOverallUptime()`: Fleet-wide uptime percentage + +**Testing Strategy:** +```go +// All calculator functions can be tested directly without mocking +func TestCalculateBlockStatus(t *testing.T) { + status := CalculateBlockStatus(start, end, now, firstSeen, pings, params, idx) + assert.Equal(t, "up", status.Status) +} +``` + +### 3. Projection (`projection.go`) + +**Purpose:** Orchestrate data flow from storage to DTOs. + +**Responsibilities:** +1. Accept `FilterCriteria` (timeframe, now) +2. Fetch raw data from store +3. Invoke calculators on raw data +4. Map results into DTOs +5. Return complete `UptimeResponse` + +**Interface Design:** +```go +type ProjectionStore interface { + GetNodes() ([]models.Node, error) + GetSensorsForUptime(cutoffStr string) ([]store.SensorUptimeData, error) + GetHeartbeatsSince(cutoffStr string) ([]store.HeartbeatData, error) + IsSensorSilenced(nodeID, sensorID string) (bool, error) +} +``` + +The minimal interface allows easy testing with mocks. + +### 4. HTTP Handler (`uptime_handler.go`) + +**Purpose:** Parse HTTP request → Call projector → Serialize response. + +**Rules:** +- Zero business logic +- Validate input early +- Delegate to projector +- Handle HTTP-specific concerns (status codes, error messages) + +```go +func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) { + // 1. Parse & validate + timeframe := r.URL.Query().Get("timeframe") + if !isValidTimeframe(timeframe) { + RespondError(w, "Invalid timeframe", http.StatusBadRequest) + return + } + + // 2. Delegate + projector := uptime.NewProjector(h.Store) + projection, err := projector.BuildUptimeProjection(uptime.FilterCriteria{...}) + if err != nil { + RespondError(w, "Failed to build projection", http.StatusInternalServerError) + return + } + + // 3. Serialize + SendJSON(w, http.StatusOK, projection) +} +``` + +## Frontend Integration + +### Data Flow + +**Initial Load:** +1. Component mounts → Fleet store's `fetchUptime()` calls `GET /api/v1/uptime?timeframe=24H` +2. API returns `UptimeResponse` → Stored in `uptimeData` +3. Component computes `hydratedGroups` → Shallow hydration with live status from fleet store +4. Template renders from `hydratedGroups.groups` (already grouped, no computation) + +**Real-time Updates (WebSocket):** +1. `heartbeat` event updates `fleet.nodes[nodeId].installedSensors[sensorId].status` +2. Vue reactivity triggers `hydratedGroups` recomputation +3. Only the "Current" block's status is updated (shallow hydration) +4. Historical blocks are unchanged (important!) + +### Frontend Responsibilities + +**Allowed:** +- Rendering the nested structure +- Shallow hydration for live status +- Filtering/sorting on UI view +- Animation/transition effects + +**Forbidden:** +- Grouping sensors by node (backend does this) +- Calculating worst_status +- Recalculating overall_uptime +- Inferring historical downtime blocks +- Joining data from multiple sources + +### Hydration Function + +```typescript +// Only update the "Current" block's live status +const hydrateGroupsWithLiveStatus = (groups) => { + return groups.map(group => ({ + ...group, + sensors: group.sensors.map(sensor => { + const blocks = [...sensor.blocks] + if (blocks.length > 0) { + const lastBlock = blocks[blocks.length - 1] + lastBlock.status = isLiveOnline ? 'up' : 'down' + } + return { ...sensor, blocks } + }) + })) +} +``` + +## API Endpoints + +### GET /api/v1/uptime + +**Query Parameters:** +- `timeframe` (string): `1H`, `24H`, `7D`, `30D` (default: `24H`) + +**Response:** +```json +{ + "timeframe": "24H", + "generated_at": "2026-05-23T14:30:00Z", + "summary": { + "overall_uptime": 99.52 + }, + "groups": [ + { + "node_id": "prod-server-1", + "node_alias": "Production Primary", + "worst_status": "up", + "sensors": [ + { + "sensor_id": "hw-tcp-tarpit", + "display_name": "TCP Tarpit", + "status": "up", + "is_silenced": false, + "blocks": [ + { + "status": "up", + "label": "Online", + "time_label": "Current" + }, + { + "status": "up", + "label": "Online", + "time_label": "1 hours ago" + } + ] + } + ] + } + ] +} +``` + +## Extending the Architecture + +### Adding a New Calculation + +1. Add function to `calculator.go`: + ```go + func CalculateSLABreach(blocks []UptimeBlock) bool { + // Pure logic only + } + ``` + +2. Add field to DTO in `dto.go`: + ```go + type UptimeSensor struct { + // ... existing fields + SLABreached bool `json:"sla_breached"` + } + ``` + +3. Invoke calculator in `projection.go`: + ```go + sensorDTO.SLABreached = CalculateSLABreach(blocks) + ``` + +4. Update frontend component to render new field (if needed) + +### Adding a New Timeframe + +1. Add case to `CalculateParams()` in `calculator.go` +2. Update `formatTimeLabel()` to handle new granularity +3. Update frontend's timeframe dropdown (if needed) +4. No handler changes needed! + +### Testing the Projection Layer + +```go +// Create a test store mock +type MockStore struct { + nodes []models.Node + sensors []store.SensorUptimeData + heartbeats []store.HeartbeatData +} + +func (m *MockStore) GetNodes() ([]models.Node, error) { + return m.nodes, nil +} + +// Run test +func TestBuildUptimeProjection(t *testing.T) { + store := &MockStore{...} + projector := uptime.NewProjector(store) + result, err := projector.BuildUptimeProjection(uptime.FilterCriteria{...}) + assert.NoError(t, err) + assert.Equal(t, "up", result.Groups[0].WorstStatus) +} +``` + +## Common Pitfalls + +### ❌ Adding business logic to the handler +```go +// WRONG +func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) { + worst := "" // Don't calculate here! +} + +// RIGHT +func (h *Handler) GetUptime(w http.ResponseWriter, r *http.Request) { + projection, _ := projector.BuildUptimeProjection(criteria) + SendJSON(w, http.StatusOK, projection) +} +``` + +### ❌ Frontend re-computing projections +```javascript +// WRONG +const groups = computed(() => { + return flatten(uptimeData).map(s => ({ + worst: calculateWorst(s.blocks) // Don't do this! + })) +}) + +// RIGHT +const hydratedGroups = computed(() => { + return hydrateWithLiveStatus(uptimeData?.groups) +}) +``` + +### ❌ Changing DTOs without versioning +```go +// WRONG: Existing frontend breaks +type UptimeSensor struct { + // Removed: SensorID string + Identifier string // Use new name +} + +// RIGHT: Add new field, deprecate old +type UptimeSensor struct { + SensorID string `json:"sensor_id"` // Keep for compatibility + Identifier string `json:"identifier"` // New field +} +``` + +## Maintenance Guidelines + +### When to Update Each Layer + +| Change | Where | Why | +|--------|-------|-----| +| Fix uptime calculation bug | `calculator.go` | Isolated, testable | +| Add time range filter | `projection.go` | Doesn't touch DTOs | +| New status type | `calculator.go` + `dto.go` | Pure logic + contract | +| Style changes | Frontend component | No backend impact | +| Fetch different data | `projection.go` store interface | Fetch layer only | + +### Backwards Compatibility + +- DTOs are immutable once released +- New fields must be optional (pointer types or omitempty) +- Never rename existing JSON fields +- New calculations should not change existing field meanings +- Frontend must handle missing optional fields gracefully + +## Summary + +The uptime projection architecture achieves: + +✅ **Type Safety**: Strict DTOs eliminate generic response objects +✅ **Separation of Concerns**: Business logic isolated in calculator.go +✅ **Testability**: Pure functions with zero external dependencies +✅ **Single Source of Truth**: All calculations in one place +✅ **Frontend Simplicity**: Template only renders, no computation +✅ **Real-time Updates**: Shallow hydration without recalculation +✅ **Maintainability**: Clear responsibilities, easy to extend + +This architecture enables the frontend to stay "dumb" (strictly rendering) while the backend becomes the authoritative source for all uptime analytics. diff --git a/Hub/internal/projections/uptime/calculator.go b/Hub/internal/projections/uptime/calculator.go new file mode 100644 index 0000000..346e0cd --- /dev/null +++ b/Hub/internal/projections/uptime/calculator.go @@ -0,0 +1,252 @@ +package uptime + +import ( + "fmt" + "time" + + "github.com/honeywire/hub/internal/store" +) + +// UptimeCalculationParams holds parameters needed for uptime calculations +type UptimeCalculationParams struct { + NumBlocks int + Delta time.Duration + ExpectedPings float64 + Cutoff time.Time +} + +// CalculateParams determines the calculation parameters based on timeframe +func CalculateParams(timeframe string, now time.Time) UptimeCalculationParams { + var numBlocks int + var delta time.Duration + var expectedPings float64 + + switch timeframe { + case "1H": + numBlocks, delta, expectedPings = 30, 2*time.Minute, 2 + case "7D": + numBlocks, delta, expectedPings = 7, 24*time.Hour, 1440 + case "30D": + numBlocks, delta, expectedPings = 30, 24*time.Hour, 1440 + case "24H": + fallthrough + default: + numBlocks, delta, expectedPings = 24, time.Hour, 60 + } + + cutoff := now.Add(-delta * time.Duration(numBlocks)).Truncate(time.Minute) + return UptimeCalculationParams{ + NumBlocks: numBlocks, + Delta: delta, + ExpectedPings: expectedPings, + Cutoff: cutoff, + } +} + +// HistoryBucket represents aggregated heartbeat data for a sensor in a time period +type HistoryBucket struct { + SensorKey string + Pings []float64 +} + +// BuildHeartbeatHistory aggregates heartbeat data into time-based buckets +func BuildHeartbeatHistory( + sensors []store.SensorUptimeData, + heartbeats []store.HeartbeatData, + params UptimeCalculationParams, +) map[string][]float64 { + history := make(map[string][]float64) + for _, s := range sensors { + historyKey := s.NodeID + ":" + s.SensorID + history[historyKey] = make([]float64, params.NumBlocks) + } + + for _, hb := range heartbeats { + parsedBucket, err := time.Parse(time.RFC3339, hb.TimeBucket) + if err != nil { + continue + } + + if parsedBucket.Before(params.Cutoff) { + continue + } + + idx := int(parsedBucket.Sub(params.Cutoff) / params.Delta) + if idx >= params.NumBlocks { + idx = params.NumBlocks - 1 + } + + historyKey := hb.NodeID + ":" + hb.SensorID + if idx >= 0 && history[historyKey] != nil { + history[historyKey][idx]++ + } + } + + return history +} + +// BlockStatus represents the computed status of a time block +type BlockStatus struct { + Status string // "up", "down", "degraded", "nodata" + Label string // Human-readable explanation +} + +// CalculateBlockStatus determines the uptime status for a single time block +func CalculateBlockStatus( + blockStart, blockEnd, now, firstSeen time.Time, + pings float64, + params UptimeCalculationParams, + blockIndex int, +) BlockStatus { + status, label := "", "" + + if blockEnd.Before(firstSeen) { + // Sensor not yet deployed at this time + status, label = "nodata", "No Data (Not Deployed Yet)" + } else { + targetPings := params.ExpectedPings + + // Adjust expected pings if deployment occurred mid-block + if firstSeen.After(blockStart) && firstSeen.Before(blockEnd) { + activeDuration := blockEnd.Sub(firstSeen) + targetPings = activeDuration.Minutes() + if targetPings > params.ExpectedPings { + targetPings = params.ExpectedPings + } + if targetPings < 1 && activeDuration > 0 { + targetPings = 1 + } + } else if blockIndex == params.NumBlocks-1 { + // For the most recent block, use actual elapsed time + activeDuration := now.Sub(blockStart) + targetPings = activeDuration.Minutes() + if targetPings > params.ExpectedPings { + targetPings = params.ExpectedPings + } + if targetPings < 1 && activeDuration > 0 { + targetPings = 1 + } + } + + if pings == 0 && targetPings >= 1 { + status, label = "down", "Offline" + } else if targetPings > 0 && pings < (targetPings*0.85) { + status, label = "degraded", fmt.Sprintf("Degraded (%.0f/%.0f pings)", pings, targetPings) + } else { + status, label = "up", "Online" + } + } + + return BlockStatus{Status: status, Label: label} +} + +// GenerateBlocks creates the heatmap blocks for a sensor +func GenerateBlocks( + sensorData store.SensorUptimeData, + history []float64, + params UptimeCalculationParams, + timeframe string, + now time.Time, +) []UptimeBlock { + firstSeenParsed, _ := time.Parse(time.RFC3339, sensorData.FirstSeen) + blocks := make([]UptimeBlock, params.NumBlocks) + + for i := 0; i < params.NumBlocks; i++ { + blockStart := params.Cutoff.Add(time.Duration(i) * params.Delta) + blockEnd := blockStart.Add(params.Delta) + + stepsAgo := params.NumBlocks - 1 - i + timeLabel := formatTimeLabel(stepsAgo, params.Delta, timeframe) + + blockStatus := CalculateBlockStatus(blockStart, blockEnd, now, firstSeenParsed, history[i], params, i) + blocks[i] = UptimeBlock{ + Status: blockStatus.Status, + Label: blockStatus.Label, + TimeLabel: timeLabel, + } + } + + return blocks +} + +// formatTimeLabel creates a human-readable time reference +func formatTimeLabel(stepsAgo int, delta time.Duration, timeframe string) string { + if stepsAgo == 0 { + return "Current" + } + + switch timeframe { + case "1H": + return fmt.Sprintf("%d mins ago", stepsAgo*int(delta.Minutes())) + case "24H": + return fmt.Sprintf("%d hours ago", stepsAgo) + case "7D", "30D": + return fmt.Sprintf("%d days ago", stepsAgo) + default: + return fmt.Sprintf("%d ago", stepsAgo) + } +} + +// ResolveWorstStatus determines the worst status among a list of statuses +func ResolveWorstStatus(statuses []string) string { + for _, status := range statuses { + if status == "down" { + return "down" + } + } + for _, status := range statuses { + if status == "degraded" { + return "degraded" + } + } + // All are "up" or "nodata" + for _, status := range statuses { + if status == "up" { + return "up" + } + } + // All are "nodata" + return "" +} + +// CalculateOverallUptime computes the fleet-wide uptime percentage +func CalculateOverallUptime(sensors []store.SensorUptimeData, history map[string][]float64, params UptimeCalculationParams, now time.Time) float64 { + if len(sensors) == 0 { + return 100.0 + } + + totalBlocks := 0 + upBlocks := 0 + + for _, sensor := range sensors { + historyKey := sensor.NodeID + ":" + sensor.SensorID + sensorHistory := history[historyKey] + if sensorHistory == nil { + continue + } + + firstSeenParsed, _ := time.Parse(time.RFC3339, sensor.FirstSeen) + + for i := 0; i < params.NumBlocks; i++ { + blockStart := params.Cutoff.Add(time.Duration(i) * params.Delta) + blockEnd := blockStart.Add(params.Delta) + + blockStatus := CalculateBlockStatus(blockStart, blockEnd, now, firstSeenParsed, sensorHistory[i], params, i) + if blockStatus.Status == "nodata" { + continue + } + + totalBlocks++ + if blockStatus.Status == "up" { + upBlocks++ + } + } + } + + if totalBlocks == 0 { + return 100.0 + } + + percentage := (float64(upBlocks) / float64(totalBlocks)) * 100.0 + return percentage +} diff --git a/Hub/internal/projections/uptime/dto.go b/Hub/internal/projections/uptime/dto.go new file mode 100644 index 0000000..97f3d10 --- /dev/null +++ b/Hub/internal/projections/uptime/dto.go @@ -0,0 +1,41 @@ +package uptime + +import "time" + +// UptimeResponse is the frontend-facing DTO for uptime data. +// This is the strict API contract - all UI rendering must derive from this structure. +type UptimeResponse struct { + Timeframe string `json:"timeframe"` + GeneratedAt time.Time `json:"generated_at"` + Summary UptimeSummary `json:"summary"` + Groups []UptimeGroup `json:"groups"` +} + +// UptimeSummary provides high-level fleet statistics. +type UptimeSummary struct { + OverallUptime float64 `json:"overall_uptime"` +} + +// UptimeGroup represents sensors grouped by a node. +type UptimeGroup struct { + NodeID string `json:"node_id"` + NodeAlias string `json:"node_alias"` + WorstStatus string `json:"worst_status"` // "up", "degraded", "down", or "" if all are nodata + Sensors []UptimeSensor `json:"sensors"` +} + +// UptimeSensor represents a single sensor's uptime history. +type UptimeSensor struct { + SensorID string `json:"sensor_id"` + DisplayName string `json:"display_name"` + Status string `json:"status"` // "up", "down", "degraded" + IsSilenced bool `json:"is_silenced"` + Blocks []UptimeBlock `json:"blocks"` +} + +// UptimeBlock represents a single time bucket in the heatmap. +type UptimeBlock struct { + Status string `json:"status"` // "up", "down", "degraded", "nodata" + Label string `json:"label"` // Human-readable status (e.g., "Offline", "Online", etc.) + TimeLabel string `json:"time_label"` // Time reference (e.g., "5 hours ago", "Current") +} diff --git a/Hub/internal/projections/uptime/projection.go b/Hub/internal/projections/uptime/projection.go new file mode 100644 index 0000000..432734b --- /dev/null +++ b/Hub/internal/projections/uptime/projection.go @@ -0,0 +1,182 @@ +package uptime + +import ( + "time" + + "github.com/honeywire/hub/internal/models" + "github.com/honeywire/hub/internal/store" +) + +// FilterCriteria holds the parameters for building an uptime projection +type FilterCriteria struct { + Timeframe string + Now time.Time +} + +// ProjectionStore defines the minimal data access needed for uptime projections +type ProjectionStore interface { + GetNodes() ([]models.Node, error) + GetSensorsForUptime(cutoffStr string) ([]store.SensorUptimeData, error) + GetHeartbeatsSince(cutoffStr string) ([]store.HeartbeatData, error) + IsSensorSilenced(nodeID, sensorID string) (bool, error) +} + +// Projector is responsible for building complete uptime projections +type Projector struct { + Store ProjectionStore +} + +// NewProjector creates a new uptime projector +func NewProjector(s ProjectionStore) *Projector { + return &Projector{Store: s} +} + +// BuildUptimeProjection constructs a complete uptime projection from raw backend data +func (p *Projector) BuildUptimeProjection(criteria FilterCriteria) (*UptimeResponse, error) { + // 1. Calculate parameters based on timeframe + params := CalculateParams(criteria.Timeframe, criteria.Now) + + // 2. Fetch raw data from store + sensors, err := p.Store.GetSensorsForUptime(params.Cutoff.Format(time.RFC3339)) + if err != nil { + return nil, err + } + + heartbeats, err := p.Store.GetHeartbeatsSince(params.Cutoff.Format(time.RFC3339)) + if err != nil { + return nil, err + } + + nodes, err := p.Store.GetNodes() + if err != nil { + return nil, err + } + + // 3. Build heartbeat history + history := BuildHeartbeatHistory(sensors, heartbeats, params) + + // 4. Build a map for fast node lookup by ID + nodesMap := make(map[string]models.Node) + for _, node := range nodes { + nodesMap[node.ID] = node + } + + // 5. Group sensors by NodeID and build DTOs + groupsMap := make(map[string]*UptimeGroup) + var allStatuses []string + + for _, sensor := range sensors { + nodeID := sensor.NodeID + historyKey := nodeID + ":" + sensor.SensorID + + // Get or create group for this node + if _, exists := groupsMap[nodeID]; !exists { + nodeAlias := nodeID + if node, ok := nodesMap[nodeID]; ok { + nodeAlias = node.Alias + } + groupsMap[nodeID] = &UptimeGroup{ + NodeID: nodeID, + NodeAlias: nodeAlias, + Sensors: make([]UptimeSensor, 0), + } + } + + // Build heatmap blocks for this sensor + sensorHistory := history[historyKey] + if sensorHistory == nil { + sensorHistory = make([]float64, params.NumBlocks) + } + + blocks := GenerateBlocks(sensor, sensorHistory, params, criteria.Timeframe, criteria.Now) + + // Determine sensor status from the most recent block + sensorStatus := "up" + if len(blocks) > 0 { + lastBlock := blocks[len(blocks)-1] + sensorStatus = lastBlock.Status + if sensorStatus == "nodata" { + sensorStatus = "up" // Treat nodata as up for status display + } + } + + // Collect statuses for worst-status calculation + blockStatuses := make([]string, len(blocks)) + for i, block := range blocks { + blockStatuses[i] = block.Status + } + allStatuses = append(allStatuses, blockStatuses...) + + // Check if sensor is silenced + isSilenced, _ := p.Store.IsSensorSilenced(nodeID, sensor.SensorID) + + // Build sensor DTO + sensorDTO := UptimeSensor{ + SensorID: sensor.SensorID, + DisplayName: sensor.SensorID, // Use SensorID as display name, can be enhanced later + Status: sensorStatus, + IsSilenced: isSilenced, + Blocks: blocks, + } + + groupsMap[nodeID].Sensors = append(groupsMap[nodeID].Sensors, sensorDTO) + } + + // 6. Calculate worst status per group + for _, group := range groupsMap { + groupStatuses := make([]string, 0) + for _, sensor := range group.Sensors { + for _, block := range sensor.Blocks { + groupStatuses = append(groupStatuses, block.Status) + } + } + group.WorstStatus = ResolveWorstStatus(groupStatuses) + } + + // 7. Convert map to sorted slice + groups := make([]UptimeGroup, 0, len(groupsMap)) + for _, group := range groupsMap { + groups = append(groups, *group) + } + + // Sort groups: unassigned last, others alphabetically + sortGroups(groups) + + // 8. Calculate overall uptime + overallUptime := CalculateOverallUptime(sensors, history, params, criteria.Now) + + // 9. Build response + response := &UptimeResponse{ + Timeframe: criteria.Timeframe, + GeneratedAt: criteria.Now, + Summary: UptimeSummary{ + OverallUptime: overallUptime, + }, + Groups: groups, + } + + return response, nil +} + +// sortGroups sorts groups with unassigned last and others alphabetically +func sortGroups(groups []UptimeGroup) { + // Simple bubble sort for small datasets + for i := 0; i < len(groups); i++ { + for j := i + 1; j < len(groups); j++ { + if shouldSwap(groups[i], groups[j]) { + groups[i], groups[j] = groups[j], groups[i] + } + } + } +} + +// shouldSwap determines if two groups should be swapped during sorting +func shouldSwap(a, b UptimeGroup) bool { + if a.NodeID == "unassigned" { + return false // unassigned stays at end + } + if b.NodeID == "unassigned" { + return true // move other groups before unassigned + } + return a.NodeID > b.NodeID +} diff --git a/Hub/ui/FRONTEND_ARCHITECTURE.md b/Hub/ui/FRONTEND_ARCHITECTURE.md new file mode 100644 index 0000000..35e8832 --- /dev/null +++ b/Hub/ui/FRONTEND_ARCHITECTURE.md @@ -0,0 +1,986 @@ +# HoneyWire Frontend Architecture & Data Flow + +This document explains the structural design, state management, data flow, and real-time update strategy of the HoneyWire frontend. It focuses on how data is stored, updated, rendered, and the critical distinction between WebSocket (realtime) and API (authoritative) data sources. + +For practical development guidelines, project structure, and component rules, see [Frontend Developer Guide](./Frontend.md). + +--- + +# Table of Contents + +1. [Layered Architecture](#layered-architecture) +2. [Core Principles](#core-principles) +3. [State Storage](#state-storage) +4. [Data Flow Lifecycle](#data-flow-lifecycle) +5. [API Data vs WebSocket Data](#api-data-vs-websocket-data) +6. [Normalization & Reactivity](#normalization--reactivity) +7. [WebSocket Integration](#websocket-integration) +8. [Bootstrap & Lifecycle](#bootstrap--lifecycle) +9. [Error Handling & Rollback](#error-handling--rollback) +10. [Debugging Guide](#debugging-guide) + +--- + +# Layered Architecture + +HoneyWire enforces strict layered architecture with unidirectional dependencies: + +``` +┌─────────────────────────────────────────┐ +│ Views & Components │ +│ (UI rendering + ephemeral state) │ +├─────────────────────────────────────────┤ +│ Stores (Pinia) │ +│ (Business logic + state ownership) │ +├─────────────────────────────────────────┤ +│ API Client · WebSocket Service │ +│ (Transport + error handling) │ +├─────────────────────────────────────────┤ +│ Utils & Helpers │ +│ (Shared functions) │ +└─────────────────────────────────────────┘ +``` + +**Critical Rule:** No layer reaches above itself. +- Views **never** call APIs directly +- Stores **never** import Vue or manage UI state +- Services **never** touch component state +- API Client **never** implements business logic + +--- + +# Core Principles + +1. **Deterministic rendering** — Same state always produces same output +2. **Centralized state ownership** — Stores are the single source of truth +3. **Optimistic responsiveness** — UI updates immediately, backend confirms asynchronously +4. **Rollback safety** — Every mutation has a clear undo path +5. **Reactive identity stability** — Array/object references preserved, never reassigned +6. **Normalized boundaries** — Data normalized once at store entry point, never in components +7. **Transport abstraction** — API layer decoupled from business logic +8. **Predictable data flow** — One direction, one owner per piece of state + +--- + +# State Storage + +HoneyWire uses three main Pinia stores, each owning a distinct domain: + +## Store: `app.js` — Application & Auth State + +**Ownership:** UI navigation, authentication, system state + +| State | Purpose | Source | +|-------|---------|--------| +| `isAuthenticated` | Shell reveal toggle | Set by App.vue after `loadAppData()` completes | +| `requiresSetup` | Initial setup flow gate | API: `GET /api/v1/setup/status` | +| `currentView` | Active page (dashboard, fleet, settings, etc.) | UI selection | +| `sidebarOpen` | Sidebar visibility toggle | UI toggle | +| `viewingArchive` | Archive view mode (vs active events) | UI toggle | +| `isArmed` | System armed/disarmed state | API: `GET /api/v1/system/state` + WS updates | +| `version` | Hub version string | API: `GET /api/v1/version` | +| `activeTimeframe` | Dashboard chart timeframe (24H, 7D, 30D, 1H) | UI selection | +| `velocityTimeframe` | Threat velocity timeframe | UI selection | + +**Actions:** +- `login(password)` — Authenticate user (returns success, does NOT set `isAuthenticated`) +- `logout()` — Invalidate session +- `completeSetup()` — Store setup credentials +- `checkSetupStatus()` — Fetch initial system state +- `checkRequiresSetup()` — Determine if hub needs setup +- `checkSystemState()` — Check if authenticated +- `toggleArmed()` — Toggle system armed state (optimistic update + rollback) + +--- + +## Store: `fleet.js` — Infrastructure State + +**Ownership:** Nodes, sensors, uptime, deployment metadata + +### State Structure + +```javascript +{ + nodes: [ + { + id: "node-abc", + alias: "production-db", + tags: ["database", "prod"], + status: "up" | "down" | "unknown" | "pending", + publicIp: "203.0.113.5", + privateIp: "10.0.1.5", + lastEvent: "2h ago", + lastHeartbeat: 1716345600000, + hasPendingConfig: false, + activeRevision: "rev_123", + desiredRevision: "rev_124", + installedSensors: [ + { + id: "tcp-tarpit-1", + name: "TCP Tarpit", + display: "Custom TCP Tarpit", + status: "up", + isSilenced: false, + events24h: 3, + osi: "Layer 4", + lastHeartbeat: 1716345600000, + envVars: { HW_SEVERITY: "high" }, + metadata: { ... } + }, + // ... more sensors + ] + }, + // ... more nodes + ], + + uptimeData: [ + { + sensor_id: "tcp-tarpit-1", + blocks: [ + { timestamp: 1716259200000, status: "up" | "down" | "degraded" | "nodata" }, + // ... 24H of blocks + ] + }, + // ... more sensors + ], + + selectedNode: "node-abc" | null, + selectedSensor: "tcp-tarpit-1" | null, + activeTimeframe: "24H" | "7D" | "30D" | "1H" +} +``` + +### Computed Properties (Indexed Access) + +For O(1) lookup performance, the store maintains computed maps: + +```javascript +// nodeMap: { [nodeId]: node } +const getNode = (nodeId) => nodeMap.value[nodeId] || null + +// sensorIndex: { [nodeId]: { [sensorId]: sensor } } +const getSensor = (nodeId, sensorId) => sensorIndex.value[nodeId]?.[sensorId] || null + +// Example: Get sensor data +const sensor = fleet.getSensor("node-abc", "tcp-tarpit-1") +``` + +**Critical:** Always use composite key `node_id + sensor_id`. Sensor IDs are only unique within a node. + +### Data Fetch Actions + +| Action | Endpoint | Purpose | When Called | +|--------|----------|---------|-------------| +| `fetchFleet()` | `GET /api/v1/nodes` | Load all nodes & sensors | Cold boot, manual refresh, WS reconnect | +| `fetchNodeDetails(nodeId)` | `GET /api/v1/nodes/{id}` | Load single node details | After add/remove sensor, manual refresh | +| `fetchUptime(timeframe)` | `GET /api/v1/uptime` | Load uptime blocks | Cold boot, timeframe change, WS `SYNC_CHARTS` | +| `fetchManifests()` | `GET /api/v1/manifests` | Load sensor catalog | Store view load | + +### Data Mutation Actions + +| Action | Endpoint | Purpose | Optimistic Update | +|--------|----------|---------|-------------------| +| `createNode(alias, tags)` | `POST /api/v1/nodes` | Create new node | Add partial node immediately | +| `updateNode(nodeId, payload)` | `PATCH /api/v1/nodes/{id}` | Update node metadata | Apply changes immediately | +| `deleteNode(nodeId)` | `DELETE /api/v1/nodes/{id}` | Delete node | Remove immediately, refetch on error | +| `addSensor(nodeId, sensorConfig)` | `POST /api/v1/nodes/{id}/sensors` | Deploy sensor | Add optimistic sensor, refetch details | +| `updateSensor(nodeId, sensorId, config)` | `PUT /api/v1/nodes/{id}/sensors/{sensorId}` | Update sensor config | Apply changes immediately | +| `removeSensor(nodeId, sensorId)` | `DELETE /api/v1/nodes/{id}/sensors/{sensorId}` | Remove sensor | Remove immediately, refetch on error | +| `toggleSilence(nodeId, sensorId, state)` | `PATCH /api/v1/nodes/{id}/sensors/{sensorId}/silence` | Silence sensor | Toggle immediately, rollback on error | + +--- + +## Store: `events.js` — Telemetry State + +**Ownership:** Intrusion events, event filtering, unread tracking + +### State Structure + +```javascript +{ + events: [ + { + id: "event-xyz", + node_id: "node-abc", + sensor_id: "tcp-tarpit-1", + source: "203.0.113.99", + target: "Auth Gateway", + severity: "critical" | "high" | "medium" | "low" | "info", + event_trigger: "malformed_jwt_detected", + is_read: 0 | 1, + is_archived: 0 | 1, + timestamp: 1716345600000, + details: { protocol: "TCP", action_taken: "logged" } + }, + // ... more events + ], + + unreadCount: 5, + activeEvent: null, + isFetching: false +} +``` + +### Computed Properties + +```javascript +// Filtered events based on current selections and view mode +filteredEvents = computed(() => { + // Filter by archive state (viewingArchive from app store) + // Filter by selectedNode (if selected) + // Filter by selectedSensor (if selected) + return events.value.filter(...) +}) +``` + +### Event Actions + +| Action | Endpoint | Purpose | +|--------|----------|---------| +| `fetchEvents(archived, nodeId, sensorId)` | `GET /api/v1/events` | Fetch events with filters | +| `markEventRead(eventId)` | `PATCH /api/v1/events/{id}/read` | Mark single event read | +| `markAllRead()` | `PATCH /api/v1/events/read` | Mark all events read | +| `archiveEvent(eventId)` | `PATCH /api/v1/events/{id}/archive` | Archive single event | +| `archiveAll()` | `PATCH /api/v1/events/archive-all` | Archive all active events | +| `handleWsEvent(payload)` | (WebSocket) | Apply incoming event from WS | + +--- + +# Data Flow Lifecycle + +## 1. User Action → Store → API → Backend + +**Example: User toggles silence on a sensor** + +``` +View clicks: "Silence Sensor" + ↓ +View calls: fleetStore.toggleSilence(nodeId, sensorId, true) + ↓ +Store Action starts: + 1. Save previous state: const previous = sensor.isSilenced + 2. OPTIMISTIC: sensor.isSilenced = true ← UI updates immediately + 3. Await API: api.patch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}/silence`, { is_silenced: true }) + ↓ +Backend processes request + ↓ +Success (2xx): + - API client returns resolved promise + - Store does nothing (UI already updated) + ↓ +Error (4xx/5xx): + - API client throws ApiError + - Store catches and ROLLBACK: sensor.isSilenced = previous + - View receives error and shows toast notification +``` + +**Key Pattern:** Optimistic first, confirm async, rollback on error. + +--- + +## 2. API Fetch → Store → Normalize → Merge → UI Update + +**Example: Fetch fleet on cold boot** + +``` +App.vue calls: await fleetStore.fetchFleet() + ↓ +Store Action: + 1. API: const res = await api.get('/api/v1/nodes') + 2. Deserialize: const raw = await res.json() [array of raw node objects] + ↓ + 3. NORMALIZE: raw.map(normalizeNode) + - raw.last_heartbeat → lastHeartbeat + - raw.is_silenced → isSilenced + - raw.installed_sensors → installedSensors + - Recursively normalize sensors + ↓ + 4. MERGE with existing: + - For each incoming node: + - If exists: mergeNode(existing, incoming) + * Update sensor array: splice/push, never reassign + * Update fields: Object.assign() + - If new: push to nodes.value + - Remove nodes not in incoming (deleted on backend) + ↓ + 5. Vue reactivity triggered: + - Watchers on nodes.value fire + - Computed properties recompute + ↓ +UI re-renders with new data +``` + +**Key Pattern:** Normalize at boundary, preserve array identity, merge existing to prevent watchers breaking. + +--- + +## 3. WebSocket Event → Service → Store Handler → UI Update + +**Example: Backend broadcasts NEW_SENSOR event** + +``` +Backend: Node deployed a sensor + ↓ +WS broadcast: { type: "NEW_SENSOR", payload: { node_id: "...", sensor: {...} } } + ↓ +Service (ws.js) receives message + ↓ +_handleMessage() parses JSON, routes by type + ↓ +Callback dispatch: this.callbacks.onNewSensor(payload) + ↓ +App.vue registered handler: + wsService.on('onNewSensor', (payload) => fleetStore.handleWsUpdate('NEW_SENSOR', payload)) + ↓ +Store.handleWsUpdate('NEW_SENSOR', payload): + 1. Get node: const node = getNode(payload.node_id) + 2. Check if sensor exists: const exists = getSensor(payload.node_id, payload.sensor.id) + 3. If not exists: + - NORMALIZE sensor: normalizeSensor(payload.sensor) + - PUSH to node's array: node.installedSensors.push(normalized) + - Mark as pending: node.hasPendingConfig = true + 4. Optionally refetch details: fetchNodeDetails(payload.node_id) + ↓ +Vue reactivity triggered: + - Component watching installedSensors sees change + - Component re-renders +``` + +**Key Pattern:** WebSocket updates are applied immediately (no rollback), optionally trigger full refetch for authoritative state. + +--- + +# API Data vs WebSocket Data + +This is the **critical distinction** between the two data sources: + +## API Data (Authoritative) + +**Characteristics:** +- **Source of truth** — represents backend state at fetch time +- **Complete** — includes all fields and nested data +- **Normalized** — consistent key naming (last_heartbeat, installed_sensors) +- **Explicit** — full payload must be fetched + +**When used:** +- Cold boot (load initial state) +- Manual refresh (user clicks "Refresh") +- WS reconnect (recover missed updates) +- Critical mutations (create/delete nodes, deploy sensors) + +**Example:** +```javascript +// Full node state fetched from backend +const res = await api.get('/api/v1/nodes') +// Returns: [ +// { +// id: "node-1", +// alias: "production-db", +// installed_sensors: [ { id: "sensor-1", ... }, ... ], +// last_heartbeat: 1716345600000, +// ... +// } +// ] +``` + +--- + +## WebSocket Data (Realtime Delta) + +**Characteristics:** +- **Incremental** — only includes changed fields +- **Immediate** — arrives within milliseconds +- **Payload-efficient** — minimal serialization +- **Event-driven** — type-specific updates + +**When used:** +- Sensor heartbeat (update last_heartbeat) +- New event detected (append to events array) +- Configuration applied (mark as synced) +- Sensor added/removed (update sensor list) + +**Example:** +```javascript +// Heartbeat update from WebSocket (minimal payload) +// Type: SENSOR_HEARTBEAT +// Payload: { +// node_id: "node-1", +// sensor_id: "sensor-1", +// timestamp: 1716345602000, +// status: "up" +// } + +// Store handler applies immediately: +const sensor = getSensor(payload.node_id, payload.sensor_id) +sensor.lastHeartbeat = payload.timestamp +sensor.status = payload.status // UI updates instantly +``` + +--- + +## Data Update Strategy + +| Scenario | API | WS | Behavior | +|----------|-----|----|----| +| **Cold boot** | ✓ | — | Full fetch from API | +| **Sensor heartbeat arrives** | — | ✓ | Immediate update (low latency) | +| **User adds sensor** | ✓ | — | Optimistic add, fetch full details | +| **WS reconnects** | ✓ | — | Full refetch (catch missed updates) | +| **New event detected** | — | ✓ | Prepend to events (immediate) | +| **Node synced** | — | ✓ | Mark as synced, fetch details | + +**Priority Rule:** WebSocket updates are applied immediately; API fetches verify and correct state. + +--- + +# Normalization & Reactivity + +## Data Normalization + +All backend payloads are normalized at the store boundary using `normalize*` functions. Components **never** normalize. + +### Example: `normalizeNode(raw)` + +```javascript +const normalizeNode = (raw) => ({ + id: raw.id || raw.node_id || raw.nodeId, // ID normalization + alias: raw.alias || raw.name || 'Unnamed Node', // Friendly name + status: raw.status || 'unknown', + publicIp: raw.publicIp || raw.public_ip || null, // snake_case → camelCase + privateIp: raw.privateIp || raw.private_ip || null, + tags: raw.tags || [], + apiKey: raw.apiKey || raw.api_key || null, + hasPendingConfig: raw.hasPendingConfig ?? raw.pending_config ?? false, + activeRevision: raw.activeRevision || raw.active_revision || '', + desiredRevision: raw.desiredRevision || raw.desired_revision || '', + lastEvent: raw.lastEvent || raw.last_event || 'Never', + lastHeartbeat: raw.lastHeartbeat || raw.last_heartbeat || null, + installedSensors: (raw.installedSensors || raw.installed_sensors || []) + .map(normalizeSensor) // Recursively normalize nested arrays +}) +``` + +**Benefits:** +- Backend schema changes absorbed at boundary +- Components work with consistent frontend schema +- Easy to handle multiple API versions +- Fallback values prevent undefined errors + +--- + +## Reactive Identity Preservation + +Vue 3 reactivity depends on object identity. If you break the identity, watchers and computed properties fail. + +### ❌ WRONG: Reassignment breaks identity + +```javascript +// This breaks Vue reactivity! +nodes.value = newArray.map(normalizeNode) // New reference = broken watchers +``` + +### ✅ CORRECT: Mutation preserves identity + +```javascript +// Method 1: splice + push (for array updates) +nodes.value.splice(0, nodes.value.length) // Clear without reassigning +incoming.forEach(node => nodes.value.push(node)) + +// Method 2: Object.assign (for object updates) +const existing = nodes.value[0] +Object.assign(existing, { alias: "new alias" }) // Mutate in-place + +// Method 3: Preserve during merge +mergeNode(existing, incoming) { + // Update sensor array safely + const incomingSensors = incoming.installedSensors || [] + if (!existing.installedSensors) existing.installedSensors = [] + + // Instead of: existing.installedSensors = incomingSensors + // Do: Update contents while preserving array reference + const existingSensors = existing.installedSensors + + // Add/update from incoming + incomingSensors.forEach(newSensor => { + const idx = existingSensors.findIndex(s => s.id === newSensor.id) + if (idx !== -1) Object.assign(existingSensors[idx], newSensor) + else existingSensors.push(newSensor) + }) + + // Remove deleted from incoming + for (let i = existingSensors.length - 1; i >= 0; i--) { + if (!incomingSensors.find(s => s.id === existingSensors[i].id)) { + existingSensors.splice(i, 1) + } + } +} +``` + +**Rule:** Always mutate arrays/objects in-place. Never reassign. Use `splice()`, `push()`, `Object.assign()`. + +--- + +# WebSocket Integration + +## Architecture + +The WebSocket layer is completely decoupled from Vue/Pinia: + +``` +┌─────────────────────┐ +│ HoneyWireWS │ ← Framework-agnostic service +│ (services/ws.js) │ No Vue imports, callback-based +└──────────┬──────────┘ + │ + │ callbacks + ↓ + ┌─────────────┐ + │ App.vue │ ← Orchestrator (registers handlers) + └──────┬──────┘ + │ routes to + ↓ + ┌─────────────────┐ + │ Stores │ ← Business logic + │ (fleet, events)│ + └─────────────────┘ + │ + ↓ + ┌─────────────────┐ + │ Components │ ← UI rendering + └─────────────────┘ +``` + +### Service Layer: `HoneyWireWS` class + +**Responsibilities:** +- Establish WebSocket connection +- Auto-reconnect with exponential backoff +- Parse incoming JSON messages +- Dispatch to registered callbacks +- No state management + +**Key Methods:** + +```javascript +const wsService = new HoneyWireWS() + +// Register handlers before connecting +wsService.on('onNewEvent', (payload) => eventsStore.handleWsEvent(payload)) +wsService.on('onSensorHeartbeat', (payload) => fleetStore.handleWsUpdate('SENSOR_HEARTBEAT', payload)) +wsService.on('onReconnect', async () => { + // Full data refetch on reconnect + await Promise.all([ + fleetStore.fetchFleet(), + fleetStore.fetchUptime(fleetStore.activeTimeframe), + eventsStore.fetchEvents(), + ]) +}) + +// Connect +wsService.connect() +``` + +### Message Types + +| Type | Payload | Handler | Purpose | +|------|---------|---------|---------| +| `NEW_EVENT` | `{ node_id, sensor_id, source, ... }` | `eventsStore.handleWsEvent()` | New intrusion detected | +| `SENSOR_HEARTBEAT` | `{ node_id, sensor_id, timestamp, status }` | `fleetStore.handleWsUpdate()` | Sensor alive, update status | +| `NEW_SENSOR` | `{ node_id, sensor: {...} }` | `fleetStore.handleWsUpdate()` | Sensor deployed | +| `DELETE_SENSOR` | `{ node_id, sensor_id }` | `fleetStore.handleWsUpdate()` | Sensor removed | +| `SILENCE_SENSOR` | `{ node_id, sensor_id, is_silenced }` | `fleetStore.handleWsUpdate()` | Sensor silenced/unsilenced | +| `NEW_NODE` | `{ id, alias, ... }` | `fleetStore.handleWsUpdate()` | Node created | +| `UPDATE_NODE` | `{ id, ...updates }` | `fleetStore.handleWsUpdate()` | Node metadata changed | +| `DELETE_NODE` | `{ node_id }` | `fleetStore.handleWsUpdate()` | Node deleted | +| `NODE_SYNCED` | `{ node_id, active_revision }` | `fleetStore.handleWsUpdate()` | Config deployed | +| `SYNC_CHARTS` | `{}` | `fleetStore.fetchUptime()` | Refetch uptime charts | + +### Auto-Reconnect Strategy + +```javascript +// Connection established +wsService.connect() // Connects to /api/v1/ws + +// Connection drops +// Automatic reconnect with exponential backoff: +// Retry 1: 3s delay +// Retry 2: 6s delay +// Retry 3: 12s delay +// ... up to 30s max delay +// Max 10 retries total + +// On successful reconnect: +// 1. wsService.onReconnect fires +// 2. App.vue handler triggers full data refetch +// 3. Any missed events/updates recovered +// 4. UI synchronized with backend state +``` + +--- + +# Bootstrap & Lifecycle + +## Cold Boot Sequence + +User loads the app (`onMounted` in App.vue): + +```javascript +onMounted(() => { + checkAuthAndInit() +}) + +const checkAuthAndInit = async () => { + try { + // 1. Check if setup is required + const needsSetup = await appStore.checkRequiresSetup() + if (needsSetup) { + appStore.requiresSetup = true // Show Setup view + return + } + + // 2. Check if authenticated (verify session cookie) + const authenticated = await appStore.checkSystemState() + if (!authenticated) { + appStore.isAuthenticated = false // Show Login view + return + } + + // 3. Load application data + await loadAppData() + + } catch (e) { + console.error("Hub connection error:", e) + appStore.isAuthenticated = false // Show Login view + } +} + +const loadAppData = async () => { + try { + // Parallel fetch: All data sources at once + await Promise.all([ + fetchConfig(), + appStore.checkSetupStatus(), // isArmed, version + fleetStore.fetchFleet(), + fleetStore.fetchUptime(fleetStore.activeTimeframe), + eventsStore.fetchEvents(), + ]) + + // Register WebSocket event handlers + wsService.on('onNewEvent', (payload) => eventsStore.handleWsEvent(payload)) + wsService.on('onNewSensor', (payload) => fleetStore.handleWsUpdate('NEW_SENSOR', payload)) + wsService.on('onDeleteSensor', (payload) => fleetStore.handleWsUpdate('DELETE_SENSOR', payload)) + wsService.on('onSilenceSensor', (payload) => fleetStore.handleWsUpdate('SILENCE_SENSOR', payload)) + wsService.on('onSensorHeartbeat', (payload) => fleetStore.handleWsUpdate('SENSOR_HEARTBEAT', payload)) + wsService.on('onNewNode', (payload) => fleetStore.handleWsUpdate('NEW_NODE', payload)) + wsService.on('onUpdateNode', (payload) => fleetStore.handleWsUpdate('UPDATE_NODE', payload)) + wsService.on('onDeleteNode', (payload) => fleetStore.handleWsUpdate('DELETE_NODE', payload)) + wsService.on('onNodeSynced', (payload) => fleetStore.handleWsUpdate('NODE_SYNCED', payload)) + wsService.on('onReconnect', async () => { + console.log("WebSocket reconnected: syncing missed data...") + await Promise.all([ + fleetStore.fetchFleet(), + fleetStore.fetchUptime(fleetStore.activeTimeframe), + eventsStore.fetchEvents(), + ]) + }) + wsService.on('onSyncCharts', () => { + fleetStore.fetchUptime(fleetStore.activeTimeframe) + }) + + // Connect WebSocket + wsService.connect() + + // CRITICAL: Set authentication AFTER all data loads + // This ensures components mount into populated stores + appStore.isAuthenticated = true + appStore.isInitialized = true + + } catch (e) { + console.error("Failed to load application data:", e) + // Graceful degradation: show dashboard even if some data failed + appStore.isAuthenticated = true + appStore.isInitialized = true + } +} +``` + +**Critical Invariant:** `isAuthenticated = true` is set **last**, after all data has been fetched. This prevents the authenticated shell from rendering before stores are populated. + +## Post-Login Sequence + +``` +User submits login form + ↓ +Login.vue calls: appStore.login(password) + ↓ +Store action: await api.post('/login', { password }) + ↓ +Backend verifies password, sets hw_auth cookie + ↓ +api.post() resolves → { success: true } + ↓ +Login.vue emits: this.$emit('login-success') + ↓ +App.vue handler: onLoginSuccess() + ↓ +appStore.requiresSetup = false +await loadAppData() ← Same as cold boot + ↓ +All data fetched, WS connected + ↓ +appStore.isAuthenticated = true ← Shell reveals + ↓ +Dashboard component mounts into populated stores +``` + +**Note:** `login()` does NOT set `isAuthenticated`. This prevents the Login component from unmounting before it emits 'login-success', which would leave `loadAppData()` uncalled. + +--- + +# Error Handling & Rollback + +## API Error Handling + +All API errors are caught at the store level, not the component level: + +```javascript +const toggleSilence = async (nodeId, sensorId, targetState) => { + const sensor = getSensor(nodeId, sensorId) + if (!sensor) return + + // 1. Save previous state + const previous = sensor.isSilenced + + // 2. OPTIMISTIC update + sensor.isSilenced = targetState + + try { + // 3. Send to backend + await api.patch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}/silence`, { + is_silenced: targetState, + }) + // Success — nothing needed (UI already updated) + + } catch (err) { + // 4. ERROR — ROLLBACK + sensor.isSilenced = previous + console.error('Failed to toggle sensor silence:', err) + throw err // Let component handle UI feedback (toast) + } +} +``` + +## API Error Class + +```javascript +class ApiError extends Error { + constructor(message, status) { + super(message) + this.name = 'ApiError' + this.status = status // HTTP status code + } +} + +// Usage in stores +try { + await api.patch(...) +} catch (err) { + if (err.status === 401) { + // Unauthorized + } else if (err.status === 409) { + // Conflict + } else { + // Generic error + } +} +``` + +## Rollback Patterns + +### Single Field +```javascript +const previous = sensor.isSilenced +sensor.isSilenced = newValue // optimistic +// On error: +sensor.isSilenced = previous +``` + +### Multiple Fields +```javascript +const previous = { + alias: node.alias, + tags: [...node.tags], + publicIp: node.publicIp, + privateIp: node.privateIp, +} +// Apply optimistic changes +Object.assign(node, updates) +// On error: +Object.assign(node, previous) +``` + +### Array State +```javascript +const sensorIdx = node.installedSensors.findIndex(s => s.id === sensorId) +const previous = sensorIdx !== -1 ? {...node.installedSensors[sensorIdx]} : null + +// Optimistic remove +if (sensorIdx !== -1) node.installedSensors.splice(sensorIdx, 1) + +// On error: +if (previous && sensorIdx !== -1) { + node.installedSensors.splice(sensorIdx, 0, previous) +} +``` + +--- + +# Debugging Guide + +## Blank Dashboard After Login + +**Symptom:** Login succeeds, but dashboard shows no data (empty nodes, no events) + +**Causes & Fixes:** + +1. **Authenticated shell revealed before stores populated** + - Check: `loadAppData()` actually completed + - Check: All `await Promise.all([...])` calls resolved + - Look at Network tab: All `/api/v1/*` requests returned 200 + - Fix: Ensure `isAuthenticated = true` is set AFTER data loads + +2. **Store state exists but components not watching** + - Check: Component has `const { nodes } = storeToRefs(fleetStore)` + - Fix: Use `storeToRefs()` to destructure (preserves reactivity) + - ❌ Wrong: `const nodes = fleetStore.nodes` (loses reactivity) + - ✅ Right: `const { nodes } = storeToRefs(fleetStore)` + +3. **Fetch failed silently** + - Check: Browser console for errors + - Check: Network tab for failed requests (4xx/5xx) + - Check: `isFetching` state didn't complete + - Fix: Handle errors in fetch actions (currently caught but logged) + +## UI Not Updating + +**Symptom:** State changed but component doesn't re-render + +**Causes & Fixes:** + +1. **Array reassignment broke reactivity** + - ❌ Wrong: `nodes.value = newArray` + - ✅ Right: Use `splice()`, `push()`, `Object.assign()` + - Debug: Check component watchers firing (Vue DevTools) + +2. **Watched property not reactive** + - Check: Using `ref()` for state (not plain objects) + - Check: Property accessed with `.value` in `