mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
updated UptimeHeatmap to completely use the backend endpoint for business logic and calculations about uptime, the components will only render data from now on.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 `<script setup>`
|
||||
- Debug: Log the ref in mounted: `console.log(nodes.value)`
|
||||
|
||||
3. **Computed property dependency missing**
|
||||
- Check: Computed function lists all reactive dependencies
|
||||
- ❌ Wrong: `computed(() => nodes.value.filter(...).length)`
|
||||
- ✅ Right: Explicitly list: `computed(() => { ... fleetStore.nodes, ... })`
|
||||
|
||||
4. **Filter/computed chain broken**
|
||||
- Debug: Log `filteredEvents.value` to verify filtering
|
||||
- Check: Filter logic correctly handles composite keys
|
||||
- Example: Filtering by `sensor_id` alone (missing `node_id`)
|
||||
|
||||
## WebSocket Not Receiving Updates
|
||||
|
||||
**Symptom:** Events appear on backend but don't reach UI
|
||||
|
||||
**Causes & Fixes:**
|
||||
|
||||
1. **WebSocket not connected**
|
||||
- Debug: Open DevTools → Network tab → WS filter
|
||||
- Check: `ws://host/api/v1/ws` shows "101 Switching Protocols"
|
||||
- If connecting: Red "X" icon = failed connection
|
||||
- Fix: Check auth (session cookie), check backend logs
|
||||
|
||||
2. **Handler not registered**
|
||||
- Check: `wsService.on('onNewEvent', handler)` called
|
||||
- Check: Called BEFORE `wsService.connect()`
|
||||
- Fix: Verify all handlers registered in `loadAppData()`
|
||||
|
||||
3. **Message not matching event type**
|
||||
- Debug: Console logs in `ws.js` _handleMessage()
|
||||
- Check: Incoming `data.type` matches registered type
|
||||
- Example: Backend sends `"NEW_SENSOR"`, code listens for `"onNewSensor"` (wrong)
|
||||
|
||||
4. **Event filtered out**
|
||||
- Check: `handleWsEvent()` filtering by `selectedNode` + `selectedSensor`
|
||||
- Example: Event has `node_id: "node-1"` but store has `selectedNode: "node-2"`
|
||||
- Debug: Log in `handleWsEvent()` to check filter logic
|
||||
|
||||
5. **Reconnect loop but no data refresh**
|
||||
- Check: `onReconnect` callback registered
|
||||
- Check: `onReconnect` actually calls `fetchFleet()` etc.
|
||||
- Debug: See console logs: "WebSocket Reconnected: Syncing missed data..."
|
||||
|
||||
## Composite Key Bugs (Sensors)
|
||||
|
||||
**Symptom:** Wrong sensor updated, cross-node collisions
|
||||
|
||||
**Example Bug:**
|
||||
```javascript
|
||||
// ❌ WRONG: Using sensor_id alone (not unique!)
|
||||
const getSensor = (sensorId) => {
|
||||
return nodes.value
|
||||
.flatMap(n => n.installedSensors)
|
||||
.find(s => s.id === sensorId)
|
||||
}
|
||||
|
||||
// If two nodes both have a sensor named "tcp-tarpit", first match is returned
|
||||
// Silencing node-2's sensor actually silences node-1's
|
||||
```
|
||||
|
||||
**Correct Pattern:**
|
||||
```javascript
|
||||
// ✅ CORRECT: Composite key (node_id + sensor_id)
|
||||
const getSensor = (nodeId, sensorId) => {
|
||||
const node = getNode(nodeId)
|
||||
return node?.installedSensors?.find(s => s.id === sensorId) || null
|
||||
}
|
||||
|
||||
// Always pass both:
|
||||
fleetStore.getSensor("node-abc", "tcp-tarpit")
|
||||
```
|
||||
|
||||
## Optimistic Update Didn't Rollback
|
||||
|
||||
**Symptom:** User clicks "Silence", UI updates, API call fails, UI doesn't revert
|
||||
|
||||
**Causes & Fixes:**
|
||||
|
||||
1. **Previous state not captured**
|
||||
- Check: Saved state before optimistic update
|
||||
- Fix: Add `const previous = sensor.isSilenced` at start
|
||||
|
||||
2. **Rollback applied to wrong object**
|
||||
- Check: `getSensor()` returns same reference as UI is using
|
||||
- Debug: `console.log(previous, sensor)` — are they same?
|
||||
- Fix: Use store getters, not local references
|
||||
|
||||
3. **Promise chain broken**
|
||||
- Check: Error handling in try/catch
|
||||
- ❌ Wrong: Fire-and-forget `api.patch().catch()`
|
||||
- ✅ Right: `await api.patch()` with explicit try/catch
|
||||
|
||||
---
|
||||
|
||||
# Summary
|
||||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Layered architecture:** Strict separation, unidirectional flow
|
||||
2. **State storage:** Three stores (app, fleet, events) each owning a domain
|
||||
3. **Data flow:** Views → Stores → API/WS → Backend; Backend → WS/API → Stores → UI
|
||||
4. **API data:** Authoritative, complete, used for cold boot and verification
|
||||
5. **WebSocket data:** Incremental, realtime, applied immediately
|
||||
6. **Normalization:** Done once at store boundary, never in components
|
||||
7. **Reactivity:** Preserve array/object identity, use splice/assign, never reassign
|
||||
8. **Error handling:** Optimistic first, rollback on failure, always catch at store level
|
||||
9. **Bootstrap:** Data fetches in parallel, WS connects last, `isAuthenticated` set last
|
||||
10. **Reconnect:** Full data refetch to catch any missed updates during disconnect
|
||||
@@ -20,19 +20,6 @@ const worstWarningBelow = ref(null)
|
||||
const handleSilence = (nodeId, sensorId) => fleetStore.toggleSilence(nodeId, sensorId)
|
||||
const handleForget = (nodeId, sensorId) => fleetStore.deleteSensor(nodeId, sensorId)
|
||||
|
||||
// Resolve node_id → alias for display
|
||||
const getNodeAlias = (nodeId) => {
|
||||
if (!nodeId || nodeId === 'unassigned') return 'Unassigned'
|
||||
const node = fleet.value.find(n => n.id === nodeId)
|
||||
return node?.alias || nodeId
|
||||
}
|
||||
|
||||
const getWorstStatus = (sensor) => {
|
||||
if (sensor.blocks.some(b => b.status === 'down')) return 'down'
|
||||
if (sensor.blocks.some(b => b.status === 'degraded')) return 'degraded'
|
||||
return null
|
||||
}
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!scrollArea.value) return
|
||||
const container = scrollArea.value
|
||||
@@ -75,49 +62,48 @@ const isSilenced = (nodeId, sensorId) => {
|
||||
return sensor ? !!sensor.isSilenced : false
|
||||
}
|
||||
|
||||
const groupedUptime = computed(() => {
|
||||
const groupsMap = new Map();
|
||||
uptimeData.value.forEach(sensor => {
|
||||
const nId = sensor.node_id || 'unassigned';
|
||||
if (!groupsMap.has(nId)) groupsMap.set(nId, { nodeId: nId, sensors: [] });
|
||||
// Inject live status from fleet store to ensure the UI feels instant
|
||||
// and doesn't lag behind waiting for the next historic SYNC_CHARTS cycle.
|
||||
let isLiveOnline = sensor.isOnline;
|
||||
const node = fleet.value.find(n => n.id === nId);
|
||||
|
||||
if (node && node.installedSensors) {
|
||||
const liveSensor = node.installedSensors.find(s => s.id === sensor.id || s.name === sensor.id);
|
||||
if (liveSensor) {
|
||||
if (typeof liveSensor.isOnline === 'boolean') isLiveOnline = liveSensor.isOnline;
|
||||
else if (typeof liveSensor.is_online === 'boolean') isLiveOnline = liveSensor.is_online;
|
||||
else if (liveSensor.status) isLiveOnline = ['online', 'alive', 'up'].includes(liveSensor.status.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = [...(sensor.blocks || [])];
|
||||
if (blocks.length > 0) {
|
||||
const lastIdx = blocks.length - 1;
|
||||
const lastBlock = { ...blocks[lastIdx] };
|
||||
|
||||
if (isLiveOnline && (lastBlock.status === 'down' || lastBlock.status === 'nodata')) {
|
||||
lastBlock.status = 'up';
|
||||
} else if (!isLiveOnline) {
|
||||
lastBlock.status = 'down';
|
||||
}
|
||||
|
||||
blocks[lastIdx] = lastBlock;
|
||||
}
|
||||
// Hydrate live status from fleet store for real-time feedback
|
||||
const hydrateGroupsWithLiveStatus = (groups) => {
|
||||
if (!groups || !Array.isArray(groups)) return groups
|
||||
|
||||
groupsMap.get(nId).sensors.push({ ...sensor, isOnline: isLiveOnline, blocks });
|
||||
});
|
||||
const groups = Array.from(groupsMap.values());
|
||||
groups.sort((a, b) => {
|
||||
if (a.nodeId === 'unassigned') return 1;
|
||||
if (b.nodeId === 'unassigned') return -1;
|
||||
return a.nodeId.localeCompare(b.nodeId);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
return groups.map(group => ({
|
||||
...group,
|
||||
sensors: (group.sensors || []).map(sensor => {
|
||||
let isLiveOnline = sensor.status === 'up'
|
||||
const node = fleet.value.find(n => n.id === group.node_id)
|
||||
|
||||
if (node && node.installedSensors) {
|
||||
const liveSensor = node.installedSensors.find(s => s.id === sensor.sensor_id || s.name === sensor.sensor_id)
|
||||
if (liveSensor) {
|
||||
if (typeof liveSensor.isOnline === 'boolean') isLiveOnline = liveSensor.isOnline
|
||||
else if (typeof liveSensor.is_online === 'boolean') isLiveOnline = liveSensor.is_online
|
||||
else if (liveSensor.status) isLiveOnline = ['online', 'alive', 'up'].includes(liveSensor.status.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = [...(sensor.blocks || [])]
|
||||
if (blocks.length > 0) {
|
||||
const lastIdx = blocks.length - 1
|
||||
const lastBlock = { ...blocks[lastIdx] }
|
||||
|
||||
if (isLiveOnline && (lastBlock.status === 'down' || lastBlock.status === 'nodata')) {
|
||||
lastBlock.status = 'up'
|
||||
} else if (!isLiveOnline) {
|
||||
lastBlock.status = 'down'
|
||||
}
|
||||
|
||||
blocks[lastIdx] = lastBlock
|
||||
}
|
||||
|
||||
return { ...sensor, isOnline: isLiveOnline, blocks }
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// Compute the hydrated groups from the API response
|
||||
const hydratedGroups = computed(() => {
|
||||
return hydrateGroupsWithLiveStatus(uptimeData.value?.groups || [])
|
||||
})
|
||||
|
||||
watch(selectedSensor, (newVal) => {
|
||||
if (newVal && selectedNode.value) {
|
||||
@@ -137,7 +123,7 @@ watch(selectedNode, (newVal) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(uptimeData, () => nextTick(checkScroll), { deep: true })
|
||||
watch(hydratedGroups, () => nextTick(checkScroll), { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(checkScroll)
|
||||
@@ -160,8 +146,8 @@ const legendItems = [
|
||||
<div class="flex items-center gap-2 mt-1 leading-none">
|
||||
<span class="text-sm text-text-m">Fleet Overall:</span>
|
||||
<span class="text-sm transition-colors duration-normal"
|
||||
:class="parseFloat(overallUptime) >= 95 ? 'text-success-main' : (parseFloat(overallUptime) >= 85 ? 'text-high' : 'text-critical')">
|
||||
{{ overallUptime }}
|
||||
:class="(uptimeData?.summary?.overall_uptime || 0) >= 95 ? 'text-success-main' : ((uptimeData?.summary?.overall_uptime || 0) >= 85 ? 'text-high' : 'text-critical')">
|
||||
{{ (uptimeData?.summary?.overall_uptime || 0).toFixed(2) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,53 +159,53 @@ const legendItems = [
|
||||
<div class="flex-1 relative mt-2 min-h-0 w-full">
|
||||
<div ref="scrollArea" @scroll.passive="checkScroll" class="absolute top-0 left-0 right-0 bottom-0 overflow-y-auto custom-scroll pr-3 pb-10">
|
||||
|
||||
<div v-show="uptimeData.length === 0" class="text-sm font-medium text-text-m py-4 text-center">No fleet data available.</div>
|
||||
<div v-show="!uptimeData?.groups || uptimeData.groups.length === 0" class="text-sm font-medium text-text-m py-4 text-center">No fleet data available.</div>
|
||||
|
||||
<div v-for="group in groupedUptime" :key="group.nodeId" :id="'group-' + group.nodeId"
|
||||
<div v-for="group in hydratedGroups" :key="group.node_id" :id="'group-' + group.node_id"
|
||||
class="transition-all duration-normal rounded-lg p-0.5 mb-0.5 border"
|
||||
:class="{
|
||||
'border-select-group-border bg-select-group-bg': selectedNode === group.nodeId && !selectedSensor,
|
||||
'border-transparent': selectedNode !== group.nodeId || selectedSensor,
|
||||
'opacity-50': (selectedNode || selectedSensor) && selectedNode !== group.nodeId
|
||||
'border-select-group-border bg-select-group-bg': selectedNode === group.node_id && !selectedSensor,
|
||||
'border-transparent': selectedNode !== group.node_id || selectedSensor,
|
||||
'opacity-50': (selectedNode || selectedSensor) && selectedNode !== group.node_id
|
||||
}">
|
||||
|
||||
<div class="px-1.5 mb-1 flex items-center gap-2 group/header"
|
||||
:class="group.nodeId !== 'unassigned' ? 'cursor-pointer' : ''"
|
||||
@click="group.nodeId !== 'unassigned' ? fleetStore.selectTarget(group.nodeId) : null">
|
||||
:class="group.node_id !== 'unassigned' ? 'cursor-pointer' : ''"
|
||||
@click="group.node_id !== 'unassigned' ? fleetStore.selectTarget(group.node_id) : null">
|
||||
|
||||
<span class="text-sm font-semibold text-text-l transition-colors duration-[var(--duration-fast)]"
|
||||
:class="group.nodeId !== 'unassigned' ? 'group-hover/header:text-text-h' : ''">
|
||||
{{ getNodeAlias(group.nodeId) }}
|
||||
:class="group.node_id !== 'unassigned' ? 'group-hover/header:text-text-h' : ''">
|
||||
{{ group.node_alias || group.node_id }}
|
||||
</span>
|
||||
|
||||
<div class="h-px flex-1 bg-border-default transition-colors duration-[var(--duration-fast)] group-hover/header:bg-text-m"></div>
|
||||
</div>
|
||||
|
||||
<div v-for="sensor in group.sensors" :key="sensor.node_id + '-' + sensor.id" :id="'row-' + sensor.node_id + '-' + sensor.id"
|
||||
<div v-for="sensor in group.sensors" :key="sensor.node_id + '-' + sensor.sensor_id" :id="'row-' + sensor.node_id + '-' + sensor.sensor_id"
|
||||
class="flex items-center w-full transition-all duration-normal px-1.5 h-7 rounded-md border"
|
||||
:class="{
|
||||
'opacity-50': selectedSensor && (selectedSensor !== sensor.id || selectedNode !== sensor.node_id),
|
||||
'bg-select-row-bg border-select-row-border shadow-sm': selectedSensor === sensor.id && selectedNode === sensor.node_id,
|
||||
'border-transparent': !selectedSensor || (selectedSensor !== sensor.id || selectedNode !== sensor.node_id),
|
||||
'has-warnings': getWorstStatus(sensor) !== null
|
||||
'opacity-50': selectedSensor && (selectedSensor !== sensor.sensor_id || selectedNode !== sensor.node_id),
|
||||
'bg-select-row-bg border-select-row-border shadow-sm': selectedSensor === sensor.sensor_id && selectedNode === sensor.node_id,
|
||||
'border-transparent': !selectedSensor || (selectedSensor !== sensor.sensor_id || selectedNode !== sensor.node_id),
|
||||
'has-warnings': sensor.status !== 'up' && sensor.status !== 'nodata'
|
||||
}"
|
||||
:data-worst-status="getWorstStatus(sensor)"
|
||||
:data-worst-status="sensor.status !== 'up' && sensor.status !== 'nodata' ? sensor.status : null"
|
||||
>
|
||||
|
||||
<div class="w-[180px] flex items-center gap-2 shrink-0 pr-2">
|
||||
|
||||
<BaseMeatballMenu :id="`${sensor.node_id}|${sensor.id}`">
|
||||
<button @click="handleSilence(sensor.node_id, sensor.id)"
|
||||
<BaseMeatballMenu :id="`${sensor.node_id}|${sensor.sensor_id}`">
|
||||
<button @click="handleSilence(sensor.node_id, sensor.sensor_id)"
|
||||
class="w-full text-left px-3 py-2 text-sm text-text-m font-medium flex items-center gap-2 hover:bg-secondary-hover transition-colors group"
|
||||
:class="isSilenced(sensor.node_id, sensor.id) ? 'text-archive-text' : 'text-text-l hover:text-text-h'">
|
||||
:class="isSilenced(sensor.node_id, sensor.sensor_id) ? 'text-archive-text' : 'text-text-l hover:text-text-h'">
|
||||
<svg class="w-3.5 h-3.5 transition-transform duration-normal group-hover:rotate-12 group-active:-rotate-12 origin-top" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path v-if="!isSilenced(sensor.node_id, sensor.id)" d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0"/>
|
||||
<path v-if="isSilenced(sensor.node_id, sensor.id)" d="M13.73 21a2 2 0 01-3.46 0m-3.9-3.9a2.032 2.032 0 01-2.37.5L4 17h12.59l3.12 3.12M3 3l18 18M18 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341c-.5.186-.967.447-1.385.772"/>
|
||||
<path v-if="!isSilenced(sensor.node_id, sensor.sensor_id)" d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0"/>
|
||||
<path v-if="isSilenced(sensor.node_id, sensor.sensor_id)" d="M13.73 21a2 2 0 01-3.46 0m-3.9-3.9a2.032 2.032 0 01-2.37.5L4 17h12.59l3.12 3.12M3 3l18 18M18 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341c-.5.186-.967.447-1.385.772"/>
|
||||
</svg>
|
||||
{{ isSilenced(sensor.node_id, sensor.id) ? 'Unsilence' : 'Silence Alert' }}
|
||||
{{ isSilenced(sensor.node_id, sensor.sensor_id) ? 'Unsilence' : 'Silence Alert' }}
|
||||
</button>
|
||||
|
||||
<button @click="handleForget(sensor.node_id, sensor.id)"
|
||||
<button @click="handleForget(sensor.node_id, sensor.sensor_id)"
|
||||
class="w-full text-left px-3 py-2 text-sm font-medium text-danger-text flex items-center gap-2 hover:bg-danger-bg transition-colors group border-t border-border-default mt-1 pt-2">
|
||||
<svg class="w-3.5 h-3.5 transition-transform duration-normal group-hover:scale-110" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 6v14a2 2 0 002 2h10a2 2 0 002-2V6M10 11v6M14 11v6" />
|
||||
@@ -231,13 +217,13 @@ const legendItems = [
|
||||
|
||||
<BaseStatusDot :status="sensor.isOnline ? 'up' : 'down'" />
|
||||
|
||||
<button @click="fleetStore.selectTarget(sensor.node_id, sensor.id)"
|
||||
<button @click="fleetStore.selectTarget(sensor.node_id, sensor.sensor_id)"
|
||||
class="font-mono text-left transition-colors cursor-pointer rounded flex items-center gap-1.5 max-w-[calc(100%-28px)] text-sm"
|
||||
:class="selectedSensor === sensor.id && selectedNode === sensor.node_id ? 'text-text-h font-bold' : 'text-text-m font-medium hover:text-text-h'"
|
||||
:title="`Node: ${getNodeAlias(sensor.node_id)}`">
|
||||
<span class="truncate">{{ formatSensorId(sensor.name) }}</span>
|
||||
:class="selectedSensor === sensor.sensor_id && selectedNode === sensor.node_id ? 'text-text-h font-bold' : 'text-text-m font-medium hover:text-text-h'"
|
||||
:title="`Node: ${group.node_alias || group.node_id}`">
|
||||
<span class="truncate">{{ formatSensorId(sensor.sensor_id) }}</span>
|
||||
|
||||
<svg v-show="isSilenced(sensor.node_id, sensor.id)" class="w-3 h-3 shrink-0 text-medium" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg v-show="isSilenced(sensor.node_id, sensor.sensor_id)" class="w-3 h-3 shrink-0 text-medium" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13.73 21a2 2 0 01-3.46 0m-3.9-3.9a2.032 2.032 0 01-2.37.5L4 17h12.59l3.12 3.12M3 3l18 18M18 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341c-.5.186-.967.447-1.385.772"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -252,7 +238,7 @@ const legendItems = [
|
||||
'bg-high': block.status === 'degraded',
|
||||
'bg-bg-inset': block.status === 'nodata'
|
||||
}"
|
||||
:title="`${block.timeLabel} - ${block.label}`">
|
||||
:title="`${block.time_label} - ${block.label}`">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,20 +115,11 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
// --- GETTERS ---
|
||||
|
||||
const overallUptime = computed(() => {
|
||||
if (!uptimeData.value || uptimeData.value.length === 0) return '0.0%'
|
||||
let validBlocks = 0
|
||||
let upBlocks = 0
|
||||
|
||||
uptimeData.value.forEach(sensor => {
|
||||
sensor.blocks.forEach(block => {
|
||||
if (block.status !== 'nodata') {
|
||||
validBlocks++
|
||||
if (block.status === 'up') upBlocks += 1
|
||||
else if (block.status === 'degraded') upBlocks += 0.8
|
||||
}
|
||||
})
|
||||
})
|
||||
return validBlocks === 0 ? '100.0%' : ((upBlocks / validBlocks) * 100).toFixed(1) + '%'
|
||||
// Use the overall_uptime from the API response if available
|
||||
if (uptimeData.value && uptimeData.value.summary && typeof uptimeData.value.summary.overall_uptime === 'number') {
|
||||
return uptimeData.value.summary.overall_uptime.toFixed(2) + '%'
|
||||
}
|
||||
return '0.0%'
|
||||
})
|
||||
|
||||
// --- ACTIONS: FETCH ---
|
||||
|
||||
Reference in New Issue
Block a user