mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
implemented better documentation for hub provisioning logic updated Frontend documentation minor ui changes
This commit is contained in:
@@ -1,176 +0,0 @@
|
||||
# Deployment Flow Summary
|
||||
|
||||
## User Flow
|
||||
|
||||
1. **User clicks "Deploy New Node"** in the Fleet UI
|
||||
2. **User enters** a node alias and optional tags
|
||||
3. **Frontend sends** `POST /api/v1/nodes`
|
||||
4. **Backend creates** a new node row and returns the generated node API key
|
||||
5. **Frontend refreshes** fleet list and can show the new API key to the operator
|
||||
6. **User opens** the new node detail screen and chooses a sensor from the catalog
|
||||
7. **User configures** env vars and clicks "Add to Node"
|
||||
8. **Frontend sends** `POST /api/v1/nodes/{id}/sensors`
|
||||
9. **Backend inserts** the sensor into the node and marks the node as pending sync
|
||||
10. **The node detail UI** shows "Pending Sync"
|
||||
11. **When the user clicks "Sync Node"** or the node agent polls the compose endpoint, the backend generates a new compose YAML. (this doesn't clear synced status!)
|
||||
|
||||
---
|
||||
|
||||
## What Gets Created on "Deploy New Node"
|
||||
|
||||
### Frontend
|
||||
- **File:** `FleetView.vue`
|
||||
- **Function:** `handleDeploySubmit()`
|
||||
- **Sends:** `alias`, `tags`
|
||||
|
||||
### Backend
|
||||
- **Files:** `nodes.go` → `CreateNode()` / `CreateNode(alias, tagsJSON)`
|
||||
- **Inserts into `nodes` table:**
|
||||
- `id` = node-...
|
||||
- `alias`
|
||||
- `api_key` = generated hw_key_...
|
||||
- `tags`
|
||||
- `pending_config` = 0
|
||||
- `created_at`, `updated_at`
|
||||
- **Returns:**
|
||||
- `node_id`
|
||||
- `apiKey`
|
||||
- `alias`
|
||||
|
||||
### Result
|
||||
A new logical node is created with:
|
||||
- a unique node ID
|
||||
- a generated node API key
|
||||
- no sensors yet
|
||||
- `pending_config = false`
|
||||
|
||||
---
|
||||
|
||||
## What Gets Created on "Add Sensor to Node"
|
||||
|
||||
### Frontend
|
||||
- **File:** `NodeDetailView.vue`
|
||||
- **Function:** `handleAddSensorToNode()`
|
||||
- **Sends:** `sensor_id`, `custom_name`, `config_values`
|
||||
|
||||
### Backend
|
||||
- **Files:** `nodes.go` → `AddNodeSensor()` / `AddSensorToNode(nodeID, sensorID, customName, configValues)`
|
||||
- **Inserts into `node_sensors` table:**
|
||||
- `node_id`
|
||||
- `sensor_id`
|
||||
- `custom_name`
|
||||
- `config_values` # Should the whole manifest / compose file be stored? how will the Node compose generator be able to distinguish sensors and associate them to the catalog's sensors, with the exact same svg icon and configs the /api/v1/compose/generate endpoint "previews" for the user?
|
||||
- `is_silenced` = 0
|
||||
- `created_at`, `updated_at`
|
||||
- **Updates the parent node:** `pending_config = 1`
|
||||
|
||||
### Result
|
||||
- the sensor is now assigned to the node
|
||||
- the node is flagged as needing a sync
|
||||
|
||||
---
|
||||
|
||||
## Revision Logic
|
||||
|
||||
### Pending Sync Tracking
|
||||
Every sensor add/edit/remove sets: `nodes.pending_config = 1`
|
||||
|
||||
### Active Revision
|
||||
When compose is applied successfully to the server (phisical node) to make sure the state doesn't drift out of sync (how can that be done?), the backend writes:
|
||||
- `nodes.active_revision = <new hash>`
|
||||
- `nodes.pending_config = 0`
|
||||
|
||||
### How the Hash Is Created
|
||||
`generateRevisionHash()` returns: `rev_` + 8-byte hex
|
||||
This becomes `HW_CONFIG_REV` in generated YAML.
|
||||
|
||||
### Summary
|
||||
- `pending_config` = desired config changed
|
||||
- `active_revision` = last synced effective config version
|
||||
|
||||
---
|
||||
|
||||
## Deployment Logic
|
||||
|
||||
### Node List and Detail Behavior
|
||||
|
||||
**GetNodes()** loads nodes and their installed sensors, including:
|
||||
- `pending_config`
|
||||
- derives node status from last heartbeat
|
||||
|
||||
**GetNodeDetails()** loads one node plus:
|
||||
- sensor metadata
|
||||
- 24h event count
|
||||
|
||||
### Sync Action
|
||||
|
||||
The UI shows a **"Pending Sync"** banner when `hasPendingConfig` is true.
|
||||
|
||||
Clicking **Sync Node** executes:
|
||||
`
|
||||
fetch('/api/v1/nodes/compose', { Authorization: Bearer <node api key> })`
|
||||
|
||||
|
||||
*This is the actual node sync / compose generation call.*
|
||||
|
||||
---
|
||||
|
||||
## How GetNodeCompose Works
|
||||
|
||||
### Endpoint
|
||||
Endpoint
|
||||
GET /api/v1/nodes/compose
|
||||
|
||||
Implementation
|
||||
|
||||
Authentication
|
||||
Uses Authorization: Bearer <api_key>
|
||||
Looks up node_id by matching api_key in nodes table
|
||||
If the key is invalid, returns 401
|
||||
|
||||
What It Does
|
||||
Authenticates the node via GetNodeByKey(token)
|
||||
|
||||
SHOULD:
|
||||
have the same exact logic as POST /api/v1/compose/generate, metter of fact it should use that endpoint internally for each sensor, using the config values found in the db (which were set by the user before they added the sensor to the node), the only thing different should be adding the HW_CONFIG_REV env variable.
|
||||
in doing so it will build the whole services list for the node basically automatically.
|
||||
|
||||
what is being done right now for this endpoint completely breaks the node deployment logic.
|
||||
|
||||
Result
|
||||
|
||||
SHOULD:
|
||||
The endpoint produces the canonical node deployment bundle
|
||||
It should not mark the node as "synced", this causes config state drifting, which is why we implemented HW_CONFIG_REV, it should only generate the final node compose file, the "synced" will be set once the config actually gets deployed (there will need to be a check on that, like waiting for heartbeats to show HW_CONFIG_REV and check it is the same for all sensors in the node.)
|
||||
That's the backend's main manual "deploy" operation.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Notes on Preview vs. Actual Compose
|
||||
|
||||
| | **UI Preview** | **Actual Deployment / Sync** |
|
||||
|---|---|---|
|
||||
| **Endpoint** | `POST /api/v1/compose/generate` | `GET /api/v1/nodes/compose` |
|
||||
| **Purpose** | Separate compose generator for manifests and preview, used while configuring a sensor | Authenticated by node API key, persisted revision state and pending flag are updated |
|
||||
|
||||
---
|
||||
|
||||
## Short Technical Summary
|
||||
|
||||
### Deploy New Node
|
||||
- creates `nodes` row
|
||||
- generates `api\_key`
|
||||
- no sensors yet
|
||||
|
||||
### Add Sensor to Node
|
||||
- creates `node\_sensors` row
|
||||
- sets `nodes.pending\_config = 1`
|
||||
|
||||
### Revision Logic
|
||||
- `pending\_config` tracks unsynced changes
|
||||
- `active\_revision` tracks last generated config version
|
||||
- sync clears pending and stores new revision
|
||||
|
||||
### Deployment Logic
|
||||
- node compose endpoint builds YAML from installed sensors
|
||||
@@ -0,0 +1,505 @@
|
||||
# HoneyWire Frontend Architecture
|
||||
|
||||
## Stack
|
||||
|
||||
- Vue 3
|
||||
- Pinia
|
||||
- Vite
|
||||
- Native Fetch API
|
||||
- Native WebSocket API
|
||||
- TailwindCSS
|
||||
- Custom OKLCH Design System
|
||||
|
||||
---
|
||||
|
||||
# Architecture Overview
|
||||
|
||||
The frontend follows a strict layered architecture:
|
||||
|
||||
| Layer | Responsibility |
|
||||
|---|---|
|
||||
| Views & Components | UI rendering only |
|
||||
| Stores (Pinia) | Global state + business logic |
|
||||
| Services | Persistent network systems |
|
||||
| Utils | Shared helpers/theme logic |
|
||||
|
||||
Components never directly manage backend state.
|
||||
|
||||
All backend communication flows through Pinia stores.
|
||||
|
||||
---
|
||||
|
||||
# Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── api/ # API composables/config loaders
|
||||
├── assets/ # Global styles & design system
|
||||
├── components/
|
||||
│ ├── dashboard/ # Dashboard widgets
|
||||
│ ├── layout/ # Shell layout components
|
||||
│ └── ui/ # Reusable design-system primitives
|
||||
│
|
||||
├── services/ # Persistent services (WebSocket)
|
||||
├── stores/ # Pinia state domains
|
||||
├── utils/ # Shared utilities/helpers
|
||||
├── views/ # Route-level screens
|
||||
│
|
||||
├── App.vue # Root orchestrator
|
||||
└── main.js # Application bootstrap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# State Architecture
|
||||
|
||||
## `app.js`
|
||||
|
||||
Global application/UI state.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- active view routing
|
||||
- archive mode
|
||||
- sidebar state
|
||||
- armed/disarmed state
|
||||
- version/config state
|
||||
- authentication/logout
|
||||
|
||||
---
|
||||
|
||||
## `fleet.js`
|
||||
|
||||
Infrastructure state.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- nodes
|
||||
- installed sensors
|
||||
- node details
|
||||
- uptime metrics
|
||||
- sensor selection
|
||||
- node deployment state
|
||||
|
||||
### Important
|
||||
|
||||
Sensors are identified using:
|
||||
|
||||
```text
|
||||
node_id + sensor_id
|
||||
```
|
||||
|
||||
This prevents cross-node collisions.
|
||||
|
||||
---
|
||||
|
||||
## `events.js`
|
||||
|
||||
Telemetry + alert pipeline.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- realtime events
|
||||
- unread counters
|
||||
- filtering
|
||||
- archive handling
|
||||
- websocket event ingestion
|
||||
|
||||
---
|
||||
|
||||
# Data Flow
|
||||
|
||||
HoneyWire uses strict unidirectional flow:
|
||||
|
||||
```text
|
||||
UI Interaction
|
||||
↓
|
||||
Pinia Action
|
||||
↓
|
||||
API Request / State Mutation
|
||||
↓
|
||||
Reactive Update
|
||||
↓
|
||||
Component Re-render
|
||||
```
|
||||
|
||||
Views never mutate state directly.
|
||||
|
||||
---
|
||||
|
||||
# WebSocket Architecture
|
||||
|
||||
## Service Layer
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/services/ws.js
|
||||
```
|
||||
|
||||
The WS service:
|
||||
|
||||
- owns socket lifecycle
|
||||
- reconnects automatically
|
||||
- emits parsed payloads
|
||||
- contains zero Vue logic
|
||||
|
||||
---
|
||||
|
||||
## App Orchestrator
|
||||
|
||||
`App.vue` wires:
|
||||
|
||||
```text
|
||||
WebSocket Events
|
||||
↓
|
||||
Store Actions
|
||||
↓
|
||||
Reactive UI Updates
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
NEW_EVENT
|
||||
↓
|
||||
eventsStore.handleWsEvent()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# View Structure
|
||||
|
||||
| View | Purpose |
|
||||
|---|---|
|
||||
| Dashboard.vue | Telemetry & analytics |
|
||||
| FleetView.vue | Node management |
|
||||
| NodeDetailView.vue | Sensor deployment/configuration |
|
||||
| Store.vue | Sensor catalog |
|
||||
| Settings.vue | Hub configuration |
|
||||
| Setup.vue | Initial setup flow |
|
||||
| Login.vue | Authentication |
|
||||
|
||||
---
|
||||
|
||||
# Component System
|
||||
|
||||
## Dashboard Components
|
||||
|
||||
Located in:
|
||||
|
||||
```text
|
||||
components/dashboard/
|
||||
```
|
||||
|
||||
Contains telemetry widgets:
|
||||
|
||||
- EventTable
|
||||
- SeverityChart
|
||||
- ThreatVelocity
|
||||
- UptimeHeatmap
|
||||
- TrafficFilters
|
||||
|
||||
These components are data-driven and stateless whenever possible.
|
||||
|
||||
---
|
||||
|
||||
## UI Component Library
|
||||
|
||||
Located in:
|
||||
|
||||
```text
|
||||
components/ui/
|
||||
```
|
||||
|
||||
Reusable primitives grouped by domain.
|
||||
|
||||
### Categories
|
||||
|
||||
| Folder | Purpose |
|
||||
|---|---|
|
||||
| branding | logos/theme |
|
||||
| feedback | alerts/modals/status |
|
||||
| forms | inputs/buttons/selectors |
|
||||
| layout | cards/widgets/page shells |
|
||||
| navigation | menus/sidebar/nav |
|
||||
|
||||
---
|
||||
|
||||
# Design System
|
||||
|
||||
HoneyWire uses a centralized token-based design system.
|
||||
|
||||
Location:
|
||||
|
||||
```text
|
||||
src/assets/style.css
|
||||
```
|
||||
|
||||
Built entirely on CSS custom properties using OKLCH color space.
|
||||
|
||||
---
|
||||
|
||||
# Theme Architecture
|
||||
|
||||
## Core Principles
|
||||
|
||||
- semantic tokens only
|
||||
- no hardcoded component colors
|
||||
- light/dark parity
|
||||
- accessibility-first contrast
|
||||
- consistent spacing scale
|
||||
|
||||
---
|
||||
|
||||
# Color System
|
||||
|
||||
## Structural Colors
|
||||
|
||||
Used for layout hierarchy.
|
||||
|
||||
Examples:
|
||||
|
||||
```css
|
||||
--bg
|
||||
--bg-base
|
||||
--bg-surface
|
||||
--border-default
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interactive Colors
|
||||
|
||||
Used for actions/buttons.
|
||||
|
||||
Examples:
|
||||
|
||||
```css
|
||||
--primary-main
|
||||
--secondary-main
|
||||
--danger-main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Severity Tokens
|
||||
|
||||
Telemetry severity colors:
|
||||
|
||||
```css
|
||||
--sev-critical
|
||||
--sev-high
|
||||
--sev-medium
|
||||
--sev-low
|
||||
--sev-info
|
||||
```
|
||||
|
||||
Used consistently across:
|
||||
|
||||
- charts
|
||||
- alerts
|
||||
- status dots
|
||||
- event tables
|
||||
|
||||
---
|
||||
|
||||
# Typography
|
||||
|
||||
Two-font system:
|
||||
|
||||
| Token | Font |
|
||||
|---|---|
|
||||
| `--font-sans` | Inter |
|
||||
| `--font-mono` | JetBrains Mono |
|
||||
|
||||
Typography is fully tokenized:
|
||||
|
||||
```css
|
||||
--text-h1
|
||||
--text-base
|
||||
--text-sm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Spacing System
|
||||
|
||||
Consistent layout rhythm:
|
||||
|
||||
```css
|
||||
--space-card-p
|
||||
--space-flow
|
||||
--space-label-gap
|
||||
```
|
||||
|
||||
No arbitrary spacing should be introduced in components.
|
||||
|
||||
---
|
||||
|
||||
# Radius & Elevation
|
||||
|
||||
## Radius Tokens
|
||||
|
||||
```css
|
||||
--radius-sm
|
||||
--radius-md
|
||||
--radius-lg
|
||||
```
|
||||
|
||||
## Shadow Tokens
|
||||
|
||||
```css
|
||||
--shadow-sm
|
||||
--shadow-md
|
||||
--shadow-lg
|
||||
```
|
||||
|
||||
All cards/modals/widgets use shared elevation values.
|
||||
|
||||
---
|
||||
|
||||
# Z-Index System
|
||||
|
||||
Strict stacking hierarchy:
|
||||
|
||||
| Token | Usage |
|
||||
|---|---|
|
||||
| `--z-dropdown` | menus |
|
||||
| `--z-overlay` | backdrops |
|
||||
| `--z-modal` | dialogs |
|
||||
| `--z-toast` | notifications |
|
||||
|
||||
Avoid arbitrary z-index usage.
|
||||
|
||||
---
|
||||
|
||||
# Tailwind Integration
|
||||
|
||||
The design system is exposed through Tailwind using:
|
||||
|
||||
```css
|
||||
@theme
|
||||
```
|
||||
|
||||
This maps CSS tokens into utility classes.
|
||||
|
||||
Example:
|
||||
|
||||
```css
|
||||
bg-bg-surface
|
||||
text-text-h
|
||||
border-border-default
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
- consistent theming
|
||||
- centralized color control
|
||||
- instant dark-mode support
|
||||
|
||||
---
|
||||
|
||||
# Dark Mode
|
||||
|
||||
Dark mode is token-driven using:
|
||||
|
||||
```css
|
||||
.dark { ... }
|
||||
```
|
||||
|
||||
Components never define separate dark styles manually unless necessary.
|
||||
|
||||
Theme switching only swaps root variables.
|
||||
|
||||
---
|
||||
|
||||
# Frontend Rules
|
||||
|
||||
## Components
|
||||
|
||||
Components SHOULD:
|
||||
|
||||
- remain presentation-focused
|
||||
- receive data via props/stores
|
||||
- emit actions upward
|
||||
|
||||
Components SHOULD NOT:
|
||||
|
||||
- perform complex business logic
|
||||
- own global state
|
||||
- duplicate API calls
|
||||
|
||||
---
|
||||
|
||||
## Stores
|
||||
|
||||
Stores are the single source of truth.
|
||||
|
||||
Stores:
|
||||
|
||||
- own async requests
|
||||
- normalize backend data
|
||||
- handle websocket mutations
|
||||
- expose reactive state
|
||||
|
||||
---
|
||||
|
||||
## Services
|
||||
|
||||
Services must remain framework-agnostic.
|
||||
|
||||
They should not:
|
||||
|
||||
- import Vue
|
||||
- import Pinia
|
||||
- mutate UI directly
|
||||
|
||||
---
|
||||
|
||||
# Debugging Rules
|
||||
|
||||
## UI Not Updating
|
||||
|
||||
Check:
|
||||
|
||||
1. component action call
|
||||
2. store mutation
|
||||
3. reactive dependency
|
||||
4. watcher trigger
|
||||
|
||||
---
|
||||
|
||||
## Realtime Issues
|
||||
|
||||
Check:
|
||||
|
||||
1. WS connection status
|
||||
2. event routing in App.vue
|
||||
3. store WS handler
|
||||
4. active filters
|
||||
|
||||
---
|
||||
|
||||
## Sensor Selection Bugs
|
||||
|
||||
Always verify:
|
||||
|
||||
```text
|
||||
node_id + sensor_id
|
||||
```
|
||||
|
||||
Selection/filtering logic must use composite keys.
|
||||
|
||||
---
|
||||
|
||||
# Frontend Philosophy
|
||||
|
||||
HoneyWire prioritizes:
|
||||
|
||||
- deterministic state flow
|
||||
- centralized business logic
|
||||
- reusable primitives
|
||||
- strict visual consistency
|
||||
- zero duplicated network logic
|
||||
- scalable component composition
|
||||
@@ -0,0 +1,302 @@
|
||||
# HoneyWire Hub Deployment Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
HoneyWire uses a centralized Hub to manage node provisioning, sensor configuration, deployment generation, and configuration reconciliation.
|
||||
|
||||
Each managed infrastructure node receives:
|
||||
|
||||
- a unique Node ID
|
||||
- a generated Node API Key
|
||||
- a dynamic deployment bundle generated by the Hub
|
||||
|
||||
The Hub acts as the single source of truth for all deployed sensors and runtime configuration state.
|
||||
|
||||
---
|
||||
|
||||
# Node Provisioning
|
||||
|
||||
## Create Node
|
||||
|
||||
### Endpoint
|
||||
|
||||
```http
|
||||
POST /api/v1/nodes
|
||||
```
|
||||
|
||||
### Purpose
|
||||
|
||||
Creates a new logical infrastructure node inside the HoneyWire fleet.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"alias": "smb-gateway",
|
||||
"tags": ["production", "gateway"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": "node-e0d6d6ca",
|
||||
"api_key": "hw_key_xxxxxxxxx",
|
||||
"alias": "smb-gateway"
|
||||
}
|
||||
```
|
||||
|
||||
### Result
|
||||
|
||||
The Hub creates:
|
||||
|
||||
- a persistent node identity
|
||||
- a unique API key
|
||||
- an empty deployment state
|
||||
|
||||
No sensors are deployed at this stage.
|
||||
|
||||
---
|
||||
|
||||
# Sensor Deployment
|
||||
|
||||
## Add Sensor to Node
|
||||
|
||||
### Endpoint
|
||||
|
||||
```http
|
||||
POST /api/v1/nodes/{id}/sensors
|
||||
```
|
||||
|
||||
### Purpose
|
||||
|
||||
Assigns a sensor configuration to a node.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"sensor_id": "hw-sensor-tcp-tarpit",
|
||||
"custom_name": "Credential Trap",
|
||||
"config_values": {
|
||||
"HW_DECOY_PORTS": "2222,3306",
|
||||
"HW_TARPIT_MODE": "hold"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Result
|
||||
|
||||
The Hub:
|
||||
|
||||
- stores the sensor configuration
|
||||
- links the sensor to the node
|
||||
- marks the node as requiring synchronization
|
||||
|
||||
---
|
||||
|
||||
# Configuration State
|
||||
|
||||
## Pending Configuration
|
||||
|
||||
Whenever sensors are:
|
||||
|
||||
- added
|
||||
- removed
|
||||
- edited
|
||||
|
||||
the Hub sets:
|
||||
|
||||
```text
|
||||
nodes.pending_config = true
|
||||
```
|
||||
|
||||
This indicates the deployed infrastructure configuration no longer matches the desired configuration stored in the Hub.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Revisions
|
||||
|
||||
Every generated deployment bundle receives a unique configuration revision:
|
||||
|
||||
```text
|
||||
HW_CONFIG_REV=rev_xxxxxxxx
|
||||
```
|
||||
|
||||
This revision is embedded into all deployed sensors.
|
||||
|
||||
### Purpose
|
||||
|
||||
Configuration revisions allow the Hub to:
|
||||
|
||||
- detect deployment drift
|
||||
- verify successful synchronization
|
||||
- reconcile desired vs active state
|
||||
|
||||
---
|
||||
|
||||
## Active Revision
|
||||
|
||||
Once deployed sensors begin reporting heartbeats with the expected revision:
|
||||
|
||||
```text
|
||||
nodes.active_revision = desired_revision
|
||||
nodes.pending_config = false
|
||||
```
|
||||
|
||||
The node is considered synchronized.
|
||||
|
||||
---
|
||||
|
||||
# Deployment Generation
|
||||
|
||||
## Canonical Deployment Endpoint
|
||||
|
||||
### Endpoint
|
||||
|
||||
```http
|
||||
GET /api/v1/nodes/compose
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```http
|
||||
Authorization: Bearer <NODE_API_KEY>
|
||||
```
|
||||
|
||||
### Purpose
|
||||
|
||||
Generates the canonical deployment bundle for the authenticated node.
|
||||
|
||||
The Hub:
|
||||
|
||||
- authenticates the node via API key
|
||||
- loads installed sensors from the database
|
||||
- loads manifests from the sensor catalog
|
||||
- merges persisted sensor configuration
|
||||
- injects runtime variables
|
||||
- injects the current configuration revision
|
||||
- generates the final Docker Compose deployment
|
||||
|
||||
---
|
||||
|
||||
## Generated Deployment Characteristics
|
||||
|
||||
Generated deployments include:
|
||||
|
||||
- hardened container defaults
|
||||
- read-only filesystems
|
||||
- dropped Linux capabilities
|
||||
- sensor-specific privileges only
|
||||
- logging policies
|
||||
- network configuration
|
||||
- deployment dependencies
|
||||
- runtime configuration variables
|
||||
|
||||
The deployment endpoint does not mark a node as synchronized.
|
||||
|
||||
Synchronization only occurs after sensors successfully reconnect and report the expected configuration revision through heartbeats.
|
||||
|
||||
---
|
||||
|
||||
# Authentication Model
|
||||
|
||||
## Node Authentication
|
||||
|
||||
HoneyWire uses API-key-based node authentication.
|
||||
|
||||
Nodes authenticate using:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <NODE_API_KEY>
|
||||
```
|
||||
|
||||
The Hub derives node identity internally from the API key.
|
||||
|
||||
Sensors never provide or control their own Node ID.
|
||||
|
||||
---
|
||||
|
||||
## Sensor Runtime Variables
|
||||
|
||||
Sensors only require the following runtime variables:
|
||||
|
||||
```text
|
||||
HW_HUB_ENDPOINT
|
||||
HW_HUB_KEY
|
||||
HW_SENSOR_ID
|
||||
HW_CONFIG_REV
|
||||
HW_TEST_MODE
|
||||
```
|
||||
|
||||
Node identity is resolved server-side by the Hub.
|
||||
|
||||
---
|
||||
|
||||
# Manual Deployment Workflow
|
||||
|
||||
## Typical Operator Flow
|
||||
|
||||
### 1. Create Node
|
||||
|
||||
The operator creates a node through the Fleet UI.
|
||||
|
||||
### 2. Configure Sensors
|
||||
|
||||
Sensors are selected from the Sensor Catalog and assigned to the node.
|
||||
|
||||
### 3. Generate Deployment
|
||||
|
||||
The node retrieves its deployment bundle from:
|
||||
|
||||
```http
|
||||
GET /api/v1/nodes/compose
|
||||
```
|
||||
|
||||
### 4. Deploy Compose Stack
|
||||
|
||||
The generated compose file is deployed to the target infrastructure host.
|
||||
|
||||
### 5. Reconciliation
|
||||
|
||||
Sensors begin sending authenticated heartbeats containing:
|
||||
|
||||
```text
|
||||
HW_CONFIG_REV
|
||||
```
|
||||
|
||||
The Hub validates the revision and marks the node as synchronized.
|
||||
|
||||
---
|
||||
|
||||
# Preview vs Deployment
|
||||
|
||||
| Feature | Preview Generator | Node Deployment |
|
||||
|---|---|---|
|
||||
| Endpoint | `POST /api/v1/compose/generate` | `GET /api/v1/nodes/compose` |
|
||||
| Purpose | UI compose preview | Production deployment generation |
|
||||
| Authentication | UI Session | Node API Key |
|
||||
| State Tracking | No | Yes |
|
||||
| Revision Injection | No | Yes |
|
||||
| Database-backed | No | Yes |
|
||||
|
||||
---
|
||||
|
||||
# Architecture Summary
|
||||
|
||||
HoneyWire follows a centralized reconciliation architecture:
|
||||
|
||||
- the Hub stores desired infrastructure state
|
||||
- nodes authenticate using API keys
|
||||
- deployments are generated dynamically
|
||||
- sensors report active runtime revisions
|
||||
- synchronization is verified through telemetry
|
||||
|
||||
This ensures:
|
||||
|
||||
- deterministic deployments
|
||||
- drift detection
|
||||
- centralized configuration management
|
||||
- secure node authentication
|
||||
- reproducible infrastructure state
|
||||
@@ -1,81 +0,0 @@
|
||||
# HoneyWire Frontend Architecture & State Management Guide
|
||||
|
||||
This document outlines the architecture, data flow, and debugging protocols for the HoneyWire v2.0.0 Vue 3 frontend. The application utilizes a decoupled, three-layer architecture utilizing Pinia for state management and native JavaScript classes for network services.
|
||||
|
||||
## 1. Architectural Overview
|
||||
|
||||
The frontend is strictly separated into three layers to ensure maintainability, testability, and separation of concerns.
|
||||
|
||||
* **Layer 1: Presentation (Vue Components)**
|
||||
Located in `src/components/` and `src/views/`. These files contain zero business logic and make no direct API calls. They exist solely to read reactive state from Pinia stores and render HTML/CSS. User interactions (clicks, inputs) trigger Pinia store actions.
|
||||
* **Layer 2: State Management (Pinia Stores)**
|
||||
Located in `src/stores/`. This is the brain of the application. Stores hold global variables, execute business logic, and handle HTTP requests.
|
||||
* **Layer 3: Services (Network Layer)**
|
||||
Located in `src/services/`. These are framework-agnostic classes that handle persistent connections (e.g., WebSockets). They contain no reactive Vue variables and communicate with the State layer via callbacks.
|
||||
|
||||
## 2. Unidirectional Data Flow
|
||||
|
||||
To prevent race conditions and layout thrashing, HoneyWire enforces a strict unidirectional data flow:
|
||||
|
||||
1. **Trigger:** A user clicks a UI element (e.g., selecting a node).
|
||||
2. **Action:** The component calls a Pinia action (e.g., `fleetStore.selectTarget(nodeId)`).
|
||||
3. **Mutation/Request:** The Pinia action updates its local state or makes an HTTP request.
|
||||
4. **Reaction:** Vue Watchers in the main orchestrator (`App.vue`) detect the state change and trigger subsequent data fetches if necessary.
|
||||
5. **Render:** The stores update their data arrays, and Vue automatically repaints the dependent components.
|
||||
|
||||
## 3. The State Domains (Pinia Stores)
|
||||
|
||||
State is divided into three domain-specific stores.
|
||||
|
||||
### A. App Store (`src/stores/app.js`)
|
||||
|
||||
Manages the global user interface and application-level configuration.
|
||||
|
||||
* **State:** `currentView`, `sidebarOpen`, `viewingArchive`, `isArmed`, `version`.
|
||||
* **Actions:** `toggleArmed()`, `logout()`.
|
||||
|
||||
### B. Fleet Store (`src/stores/fleet.js`)
|
||||
|
||||
Manages infrastructure assets (Nodes and Sensors) and their uptime metrics.
|
||||
|
||||
* **State:** `sensors`, `uptimeData`, `selectedNode`, `selectedSensor`, `activeTimeframe`.
|
||||
* **Key Concept (Composite Keys):** Sensors are uniquely identified by a composite key consisting of both `node_id` AND `sensor_id`. All array filtering, finding, and deletion logic within this store strictly enforces this composite check to prevent cross-node collisions.
|
||||
* **Actions:** `fetchFleet()`, `fetchUptime()`, `selectTarget()`, `forgetSensor()`, `toggleSilence()`, `handleWsUpdate()`.
|
||||
|
||||
### C. Events Store (`src/stores/events.js`)
|
||||
|
||||
Manages telemetry, threat alerts, and event archiving.
|
||||
|
||||
* **State:** `events`, `unreadCount`, `isFetching`.
|
||||
* **Getters:** `filteredEvents` (Evaluates the base `events` array against the `viewingArchive` state from the App Store and the `selectedNode`/`selectedSensor` state from the Fleet Store).
|
||||
* **Actions:** `fetchEvents()`, `markAllRead()`, `archiveEvent()`, `archiveAll()`, `purgeEvents()`, `handleWsEvent()`.
|
||||
|
||||
## 4. The Orchestrator (`App.vue`)
|
||||
|
||||
`App.vue` serves as the application's central nervous system. It handles initialization and wires the Service layer to the State layer.
|
||||
|
||||
* **Initialization:** On mount, it checks authentication. If valid, it triggers the initial `fetchFleet`, `fetchUptime`, and `fetchEvents` actions.
|
||||
* **Service Routing:** It instantiates the `HoneyWireWS` class and maps its event emitters directly to the appropriate Pinia store actions (e.g., routing 'NEW_EVENT' payloads to `eventsStore.handleWsEvent`).
|
||||
* **Cross-Store Reactivity:** It contains the root-level Vue `watch` functions. For example, it watches `fleetStore.selectedNode` and calls `eventsStore.fetchEvents()` when a change is detected.
|
||||
|
||||
## 5. Debugging Guide
|
||||
|
||||
When encountering UI or state issues, follow this structured troubleshooting protocol.
|
||||
|
||||
### Scenario A: A button click does not update the UI
|
||||
|
||||
1. **Check the Component:** Verify the component is calling the correct store action without attempting to mutate the state directly.
|
||||
2. **Check the Store Action:** Open the respective store and ensure the action is properly updating the state variable (remembering to use `.value` for refs in composition API stores).
|
||||
3. **Check the Watcher:** If the UI update requires an API call (e.g., clicking the "7D" timeframe), verify that `App.vue` has an active watcher on that specific state variable to trigger the fetch.
|
||||
|
||||
### Scenario B: Filtering or selecting sensors behaves erratically
|
||||
|
||||
1. **Verify Composite Keys:** Ensure the UI is passing both `node_id` and `sensor_id` to the store action.
|
||||
2. **Check Action Logic:** Review `selectTarget` in `fleet.js`. Ensure it explicitly handles the difference between clicking a specific sensor (requires clearing both node and sensor on toggle) versus clicking a node group.
|
||||
3. **Check the Getter:** Review `filteredEvents` in `events.js`. Ensure it is pulling the correct selection state from the Fleet Store before filtering the events array.
|
||||
|
||||
### Scenario C: Real-time events are not appearing
|
||||
|
||||
1. **Check Network Tab:** Verify the WebSocket connection (`/api/v1/ws`) is returning a `101 Switching Protocols` status.
|
||||
2. **Check the Service Router:** In `App.vue`, ensure `wsService.on('onNewEvent', ...)` is actively passing payloads to `eventsStore.handleWsEvent`.
|
||||
3. **Check Filter State:** A new event will only appear in the active queue if it matches the current UI filters. Verify that `handleWsEvent` in `events.js` evaluates the incoming payload's `node_id` against the currently selected filters before unshifting it into the array.
|
||||
@@ -148,9 +148,27 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const updateNode = async (nodeId, payload) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/nodes/${nodeId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update node')
|
||||
|
||||
// Sync state from backend to ensure UI matches reality
|
||||
await fetchFleet()
|
||||
} catch (err) {
|
||||
console.error('Failed to update node:', err)
|
||||
throw err // Throw back to component for UI rollback
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes, uptimeData, selectedNode, selectedSensor, activeTimeframe,
|
||||
overallUptime,
|
||||
fetchFleet, fetchUptime, selectTarget, deleteNode, deleteSensor, toggleSilence, silenceNode, handleWsUpdate
|
||||
fetchFleet, fetchUptime, selectTarget, deleteNode, deleteSensor, toggleSilence, silenceNode, handleWsUpdate,
|
||||
updateNode,
|
||||
}
|
||||
})
|
||||
@@ -67,7 +67,7 @@ const copyCommand = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 sm:gap-6 h-full max-w-[1600px] mx-auto w-full px-2 sm:px-4 lg:px-6 ">
|
||||
<div class="flex flex-col gap-4 sm:gap-6 h-full max-w-[1600px] mx-auto w-full px-2 sm:px-4 lg:px-6 mb-16">
|
||||
|
||||
<TrafficFilters />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useFleetStore } from '../stores/fleet'
|
||||
import PageHeader from '../components/ui/layout/PageHeader.vue'
|
||||
@@ -21,8 +22,9 @@ onMounted(() => {
|
||||
const showDeployModal = ref(false)
|
||||
const deployStep = ref(1)
|
||||
const newNodeForm = ref({ alias: '' })
|
||||
const tagInput = ref('')
|
||||
const tagsList = ref([])
|
||||
const editingTagNodeId = ref(null)
|
||||
const newTagValue = ref('')
|
||||
const tagInputRefs = ref({}) // Dictionary to hold refs for multiple nodes
|
||||
const generatedNodeKey = ref('')
|
||||
|
||||
// --- EDIT MODAL STATE ---
|
||||
@@ -30,16 +32,63 @@ const showEditModal = ref(false)
|
||||
const editNodeForm = ref({ id: '', alias: '', publicIp: '', privateIp: '', apiKey: '' })
|
||||
|
||||
// --- TAGS LOGIC ---
|
||||
const addTag = () => {
|
||||
const val = tagInput.value.trim()
|
||||
if (val && !tagsList.value.includes(val)) {
|
||||
tagsList.value.push(val)
|
||||
const enableTagEdit = async (nodeId) => {
|
||||
editingTagNodeId.value = nodeId
|
||||
await nextTick()
|
||||
// Auto-focus the specific input for this node
|
||||
if (tagInputRefs.value[nodeId]) {
|
||||
tagInputRefs.value[nodeId].focus()
|
||||
}
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
const removeTag = (index) => {
|
||||
tagsList.value.splice(index, 1)
|
||||
const cancelTag = () => {
|
||||
editingTagNodeId.value = null
|
||||
newTagValue.value = ''
|
||||
}
|
||||
|
||||
const saveTag = async (node) => {
|
||||
const val = newTagValue.value.trim()
|
||||
if (val && !node.tags.includes(val)) {
|
||||
const originalTags = [...node.tags]
|
||||
const newTags = [...node.tags, val]
|
||||
|
||||
// Optimistic UI Update
|
||||
node.tags = newTags
|
||||
|
||||
try {
|
||||
await fleetStore.updateNode(node.id, {
|
||||
alias: node.alias,
|
||||
tags: newTags,
|
||||
publicIp: node.publicIp,
|
||||
privateIp: node.privateIp
|
||||
})
|
||||
} catch (err) {
|
||||
// Rollback on failure
|
||||
node.tags = originalTags
|
||||
}
|
||||
}
|
||||
cancelTag()
|
||||
}
|
||||
|
||||
const removeTag = async (node, index) => {
|
||||
const originalTags = [...node.tags]
|
||||
const newTags = [...node.tags]
|
||||
newTags.splice(index, 1)
|
||||
|
||||
// Optimistic UI Update
|
||||
node.tags = newTags
|
||||
|
||||
try {
|
||||
await fleetStore.updateNode(node.id, {
|
||||
alias: node.alias,
|
||||
tags: newTags,
|
||||
publicIp: node.publicIp,
|
||||
privateIp: node.privateIp
|
||||
})
|
||||
} catch (err) {
|
||||
// Rollback on failure
|
||||
node.tags = originalTags
|
||||
}
|
||||
}
|
||||
|
||||
// --- DATA MAPPING ---
|
||||
@@ -76,7 +125,7 @@ const handleDeploySubmit = async () => {
|
||||
const response = await fetch('/api/v1/nodes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ alias: newNodeForm.value.alias, tags: tagsList.value }),
|
||||
body: JSON.stringify({ alias: newNodeForm.value.alias}),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create node')
|
||||
@@ -99,7 +148,6 @@ const handleOpenEditNode = (node) => {
|
||||
privateIp: node.privateIp || '',
|
||||
apiKey: node.apiKey || ''
|
||||
}
|
||||
tagsList.value = [...(node.tags || [])]
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
@@ -133,7 +181,6 @@ const handleEditSubmit = async () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
alias: editNodeForm.value.alias,
|
||||
tags: tagsList.value,
|
||||
publicIp: editNodeForm.value.publicIp,
|
||||
privateIp: editNodeForm.value.privateIp
|
||||
}),
|
||||
@@ -153,8 +200,6 @@ const closeDeployModal = () => {
|
||||
setTimeout(() => {
|
||||
deployStep.value = 1
|
||||
newNodeForm.value.alias = ''
|
||||
tagsList.value = []
|
||||
tagInput.value = ''
|
||||
generatedNodeKey.value = ''
|
||||
}, 300)
|
||||
}
|
||||
@@ -201,7 +246,7 @@ const handleCopy = async (id, text) => {
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-5 auto-rows-max">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-5 auto-rows-max">
|
||||
|
||||
<BaseWidget v-for="node in displayNodes" :key="node.id" class="flex flex-col h-full min-h-[280px]">
|
||||
|
||||
@@ -263,10 +308,29 @@ const handleCopy = async (id, text) => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 mb-4">
|
||||
<span v-for="tag in node.tags" :key="tag" class="px-2 py-0.5 bg-bg-inset border border-border-default text-text-m text-sm font-medium rounded-md tracking-wider">
|
||||
<span v-for="(tag, index) in node.tags" :key="tag"
|
||||
class="px-2 py-0.5 bg-bg-inset border border-border-default text-text-m text-sm font-medium rounded-md tracking-wider flex items-center gap-1.5 group/tag transition-colors hover:border-text-m">
|
||||
{{ tag }}
|
||||
<button @click.stop="removeTag(node, index)" class="opacity-0 group-hover/tag:opacity-100 text-text-m hover:text-danger-text transition-all outline-none focus:opacity-100">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
<button @click.stop="openEditModal(node)" class="px-1.5 py-0.5 border border-dashed border-border-default text-text-m text-sm rounded-md hover:text-text-h transition-colors">
|
||||
|
||||
<div v-if="editingTagNodeId === node.id" class="relative flex items-center">
|
||||
<span class="absolute left-2 text-text-m text-sm pointer-events-none">+</span>
|
||||
<input
|
||||
:ref="el => { if (el) tagInputRefs[node.id] = el }"
|
||||
v-model="newTagValue"
|
||||
@keyup.enter="saveTag(node)"
|
||||
@keyup.esc="cancelTag"
|
||||
@blur="cancelTag"
|
||||
class="pl-5 pr-2 py-0.5 bg-input-bg border border-primary-main text-text-h text-sm rounded-md focus:outline-none ring-1 ring-focus-ring w-32 shadow-sm transition-all placeholder:text-text-m/50"
|
||||
placeholder="tag name..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button v-else @click.stop="enableTagEdit(node.id)"
|
||||
class="px-1.5 py-0.5 border border-dashed border-border-default text-text-m text-sm rounded-md hover:text-text-h hover:border-text-m transition-colors outline-none focus:ring-1 focus:ring-focus-ring">
|
||||
+ Tag
|
||||
</button>
|
||||
</div>
|
||||
@@ -346,27 +410,6 @@ const handleCopy = async (id, text) => {
|
||||
<label class="block text-sm font-medium text-text-h mb-1.5">Node Alias</label>
|
||||
<BaseInput v-model="newNodeForm.alias" placeholder="e.g., AWS-East-Gateway" autofocus />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-h mb-1.5">Tags (Optional)</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-if="tagsList.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<span v-for="(tag, index) in tagsList" :key="tag"
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-bg-inset border border-border-default text-text-m text-xs font-medium rounded-md tracking-wider">
|
||||
{{ tag }}
|
||||
<button @click="removeTag(index)" class="text-text-m hover:text-text-h hover:text-danger-text transition-colors outline-none">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BaseInput
|
||||
v-model="tagInput"
|
||||
@keydown.enter.prevent="addTag"
|
||||
placeholder="Type a tag and press Enter..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-5 border-t border-border-default mt-6">
|
||||
<BaseButton variant="ghost" @click="closeDeployModal">Cancel</BaseButton>
|
||||
@@ -460,22 +503,6 @@ const handleCopy = async (id, text) => {
|
||||
<BaseInput v-model="editNodeForm.privateIp" placeholder="e.g. 10.0.0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-h mb-1.5">Tags</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-if="tagsList.length > 0" class="flex flex-wrap gap-1.5">
|
||||
<span v-for="(tag, index) in tagsList" :key="tag"
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-bg-inset border border-border-default text-text-m text-xs font-medium rounded-md tracking-wider">
|
||||
{{ tag }}
|
||||
<button @click="removeTag(index)" class="text-text-m hover:text-danger-text transition-colors outline-none">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<BaseInput v-model="tagInput" @keydown.enter.prevent="addTag" placeholder="Type a tag and press Enter..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-5 border-t border-border-default mt-6">
|
||||
<BaseButton variant="ghost" @click="showEditModal = false">Cancel</BaseButton>
|
||||
|
||||
@@ -544,7 +544,7 @@ const applyHighlighting = () => {
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-h mb-4 mt-2">Deployed Sensors</h3>
|
||||
<div v-if="node.installedSensors.length > 0" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
|
||||
<div v-if="node.installedSensors.length > 0" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<div v-for="sensor in node.installedSensors" :key="sensor.id" class="bg-bg-surface border border-border-default rounded-lg p-4 flex flex-col group hover:border-text-m transition-colors shadow-sm relative overflow-hidden">
|
||||
|
||||
<div class="absolute top-0 left-0 right-0 h-1 transition-colors" :class="sensor.status === 'up' ? 'bg-success-main' : 'bg-danger-main'"></div>
|
||||
|
||||
Reference in New Issue
Block a user