implemented better documentation for hub provisioning logic updated Frontend documentation minor ui changes

This commit is contained in:
AndReicscs
2026-05-19 19:51:35 +00:00
parent b2837a9112
commit 712edf42ca
8 changed files with 910 additions and 315 deletions
-176
View File
@@ -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
+505
View File
@@ -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
+302
View File
@@ -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
-81
View File
@@ -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.
+19 -1
View File
@@ -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,
}
})
+1 -1
View File
@@ -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 />
+82 -55
View File
@@ -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>
+1 -1
View File
@@ -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>