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:
AndReicscs
2026-05-23 08:26:56 +00:00
parent 4e9f51165d
commit 97cd395fec
10 changed files with 1949 additions and 293 deletions
-25
View File
@@ -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")
-168
View File
@@ -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
}
+47
View File
@@ -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
}
+41
View File
@@ -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
}
+986
View File
@@ -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>
+5 -14
View File
@@ -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 ---