refactor(ui): centralize API/state architecture and remove view-level networking

Introduced shared API client, store-owned mutations, deterministic auth bootstrap flow, and optimistic state handling across the frontend.
This commit is contained in:
AndReicscs
2026-05-20 14:57:11 +00:00
parent a8a37d9fe6
commit ec474ca87d
13 changed files with 1470 additions and 1072 deletions
+378 -361
View File
@@ -2,128 +2,303 @@
## Stack
- Vue 3
- Pinia
- Vite
- Native Fetch API
- Native WebSocket API
- TailwindCSS
- Custom OKLCH Design System
Vue 3 · Pinia · Vite · TailwindCSS · Native WebSocket · OKLCH Design System
---
# Architecture Overview
The frontend follows a strict layered architecture:
Strict layered architecture. Data flows one way. Each layer owns its responsibility and nothing else.
| Layer | Responsibility |
|---|---|
| Views & Components | UI rendering only |
| Stores (Pinia) | Global state + business logic |
| Services | Persistent network systems |
| Utils | Shared helpers/theme logic |
```
┌─────────────────────────────────┐
Views & Components │ UI rendering + ephemeral state
├─────────────────────────────────┤
│ Stores (Pinia) │ Business logic + state ownership
├─────────────────────────────────┤
│ API Client · Services │ Transport + error normalization
├─────────────────────────────────┤
│ Utils │ Shared helpers
└─────────────────────────────────┘
```
Components never directly manage backend state.
**No layer reaches above itself.** Views never call APIs. Stores never import Vue. Services never touch state.
All backend communication flows through Pinia stores.
---
# Core Principles
- Deterministic state flow
- Centralized business logic
- Optimistic UI with rollback safety
- Transport abstraction
- Framework-agnostic services
- Zero duplicated network logic
- Stable reactive identity
- Normalized backend data
---
# 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
```
src/
├── api/
│ ├── client.js # Centralized HTTP client
│ └── useConfig.js # Config loader composable
├── assets/
│ └── style.css # Design system tokens
├── components/
│ ├── dashboard/ # Dashboard widgets
│ ├── layout/ # App shell components
│ └── ui/ # Reusable UI primitives
├── services/
│ └── ws.js # WebSocket service
├── stores/
│ ├── app.js # App + auth state
│ ├── events.js # Event pipeline
│ └── fleet.js # Infrastructure state
├── utils/
│ ├── chartConfig.js
│ ├── theme.js
│ └── useDropdown.js
├── views/
│ ├── Dashboard.vue
│ ├── FleetView.vue
│ ├── Login.vue
│ ├── NodeDetailView.vue
│ ├── Settings.vue
│ ├── Setup.vue
│ └── Store.vue
├── App.vue # Root orchestrator
└── main.js # Bootstrap
```
---
# Layer Responsibilities
## Views
Own ephemeral UI state only:
- Modal open/close
- Form input values
- Local loading flags
- Layout composition
**Never:** call APIs, normalize data, own business state, implement rollback.
---
## Components
Presentation-focused primitives.
**Should:** receive data via props/stores, emit actions upward, stay stateless when possible.
**Should not:** import the API client, call `fetch()`, own business logic, normalize backend payloads.
---
## Stores
Single source of truth. All backend communication flows through stores.
Stores own:
- Async requests
- Optimistic mutations + rollback
- Backend normalization
- WebSocket mutations
- Derived + indexed state
- Selection state
---
## API Client
`src/api/client.js` — centralized HTTP transport.
```js
api.get(url)
api.post(url, body)
api.patch(url, body)
api.delete(url)
api.request(url, options) // non-JSON bodies, custom headers
```
The client owns transport, serialization, and error normalization. Nothing else.
Non-2xx throws `ApiError` with `.status` and `.message`. Stores catch and rollback.
**The client does NOT own:** business logic, rollback behavior, Vue state.
---
# State Architecture
## `app.js`
## `app.js` — Application + Auth
Global application/UI state.
| Responsibility | Key State |
|---|---|
| Auth lifecycle | `isAuthenticated`, `login()` |
| Setup flow | `requiresSetup`, `completeSetup()` |
| Active routing | `currentView` |
| Sidebar | `sidebarOpen` |
| Archive mode | `viewingArchive` |
| Armed state | `isArmed`, `toggleArmed()` |
| Version | `version` |
| Timeframes | `activeTimeframe`, `velocityTimeframe` |
### Responsibilities
### Auth Lifecycle Rule
- active view routing
- archive mode
- sidebar state
- armed/disarmed state
- version/config state
- authentication/logout
> **`login()` does NOT set `isAuthenticated`.** App.vue controls shell reveal.
---
This prevents two critical bugs:
## `fleet.js`
1. **Login unmount race** — if `isAuthenticated` flips during `login()`, the Login component unmounts before emitting its success event, so `loadAppData()` never runs.
2. **Empty store mount** — if the shell reveals before stores populate, Dashboard mounts into empty data.
Infrastructure state.
**Correct sequence:**
### Responsibilities
- nodes
- installed sensors
- node details
- uptime metrics
- sensor selection
- node deployment state
### Important
Sensors are identified using:
```text
node_id + sensor_id
```
login() succeeds → returns { success: true }
Login emits 'login-success'
App.vue calls loadAppData()
stores populate + WebSocket connects
isAuthenticated = true ← shell revealed LAST
```
This prevents cross-node collisions.
---
## `fleet.js` — Infrastructure
| Responsibility | Key State |
|---|---|
| Nodes | `nodes`, `fetchFleet()`, `createNode()` |
| Sensors | `getSensor()`, indexed access |
| Uptime | `uptimeData`, `fetchUptime()` |
| Deployment | `deployingSensors`, `deploySensor()` |
| Syncing | `syncingNodes`, `syncNode()` |
| WS updates | `handleWsUpdate()` |
### Sensor Identity
> **Always use `node_id + sensor_id` as a composite key.**
Never reference `sensor_id` globally — it's only unique within a node. Cross-node collisions will occur otherwise.
### Normalization
Backend payloads are normalized once at the store boundary:
```js
raw.last_heartbeat lastHeartbeat
```
Components never normalize. They consume stable, frontend-safe structures.
### Reactive Merge
Preserve array identity. Never reassign:
```js
// ✅ Safe — preserves identity
array.splice(index, 1, newItem)
Object.assign(existing, updates)
// ❌ Breaks watchers and computed dependencies
array = filteredArray
```
### Indexed Access
O(1) lookup via computed maps:
```js
getNode(nodeId)
getSensor(nodeId, sensorId)
```
Avoids repeated nested scans across components.
---
## `events.js`
## `events.js` — Telemetry Pipeline
Telemetry + alert pipeline.
### Responsibilities
- realtime events
- unread counters
- filtering
- archive handling
- websocket event ingestion
| Responsibility | Key State |
|---|---|
| Realtime events | `events`, `handleWsEvent()` |
| Filtering | `filteredEvents` (computed) |
| Unread tracking | `unreadCount`, `markEventRead()` |
| Archive | `archiveEvent()`, `archiveAll()` |
| Bulk operations | `purgeEvents()` |
---
# Data Flow
HoneyWire uses strict unidirectional flow:
Strict unidirectional:
```text
UI Interaction
```
User Action → View → Store Action → API Client → Backend
Pinia Action
API Request / State Mutation
Reactive Update
Component Re-render
Store Mutation → Reactive Update → Component Re-render
```
Views never mutate state directly.
Views never mutate persistent state directly.
---
# Bootstrap Sequences
## Cold Boot (page load)
```
onMounted()
→ checkRequiresSetup()
→ checkSystemState()
→ loadAppData()
→ fetchConfig()
→ checkSetupStatus()
→ fetchFleet() + fetchUptime() + fetchEvents()
→ connect WebSocket
→ isAuthenticated = true
```
## Post-Login
```
login() → { success: true }
→ Login emits 'login-success'
→ onLoginSuccess()
→ loadAppData()
→ isAuthenticated = true
```
## Post-Setup
```
completeSetup() → { success: true }
→ Setup emits 'setup-complete'
→ onSetupComplete()
→ loadAppData()
→ isAuthenticated = true
```
**Invariant:** `isAuthenticated` is set **after** all data loads. Components mount into populated stores.
---
@@ -131,40 +306,34 @@ Views never mutate state directly.
## Service Layer
Location:
`src/services/ws.js` — framework-agnostic.
```text
src/services/ws.js
```
The WS service:
- owns socket lifecycle
- reconnects automatically
- emits parsed payloads
- contains zero Vue logic
---
- Owns socket lifecycle
- Auto-reconnects
- Emits parsed payloads
- Zero Vue/Pinia imports
## App Orchestrator
`App.vue` wires:
`App.vue` wires events to store actions:
```text
WebSocket Events
Store Actions
Reactive UI Updates
```
NEW_EVENT → eventsStore.handleWsEvent()
NEW_SENSOR → fleetStore.handleWsUpdate('NEW_SENSOR')
DELETE_SENSOR → fleetStore.handleWsUpdate('DELETE_SENSOR')
NODE_SYNCED → fleetStore.handleWsUpdate('NODE_SYNCED')
...
```
Example:
## Smart Reconnect
On reconnect, refetch authoritative state:
```text
NEW_EVENT
eventsStore.handleWsEvent()
```
WS reconnect → fetchFleet() + fetchUptime() + fetchEvents()
```
Guarantees recovery of any missed realtime events.
---
@@ -172,245 +341,91 @@ eventsStore.handleWsEvent()
| 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 |
| Dashboard | Telemetry & analytics |
| FleetView | Node management |
| NodeDetailView | Sensor deployment & configuration |
| Store | Sensor catalog |
| Settings | Hub configuration |
| Setup | Initial setup flow |
| Login | Authentication |
---
# Component System
## Dashboard Components
## Dashboard Widgets
Located in:
```text
components/dashboard/
```
Contains telemetry widgets:
`components/dashboard/` — data-driven, stateless when possible:
- EventTable
- SeverityChart
- ThreatVelocity
- UptimeHeatmap
- TrafficFilters
- UptimeHeatmap
These components are data-driven and stateless whenever possible.
## UI Primitives
---
`components/ui/` — reusable design-system primitives:
## UI Component Library
Located in:
```text
components/ui/
```
Reusable primitives grouped by domain.
### Categories
| Folder | Purpose |
| Category | Contents |
|---|---|
| branding | logos/theme |
| feedback | alerts/modals/status |
| forms | inputs/buttons/selectors |
| layout | cards/widgets/page shells |
| navigation | menus/sidebar/nav |
| branding | Logos, theme elements |
| 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.
`src/assets/style.css` — centralized token system on OKLCH color space.
Location:
## Principles
```text
src/assets/style.css
```
- Semantic tokens only — no hardcoded colors in components
- Accessibility-first contrast
- Dark/light parity
- Theme switching swaps root variables only
Built entirely on CSS custom properties using OKLCH color space.
## Token Categories
---
| Category | Examples | Usage |
|---|---|---|
| Structural | `--bg`, `--bg-surface`, `--border-default` | Layout hierarchy |
| Interactive | `--primary-main`, `--secondary-main`, `--danger-main` | Actions & states |
| Severity | `--sev-critical` through `--sev-info` | Charts, alerts, tables, status |
| Typography | `--text-h1`, `--text-base`, `--text-sm` | All text sizing |
| Spacing | `--space-card-p`, `--space-flow` | Layout rhythm |
| Elevation | `--radius-sm`, `--shadow-md` | Cards, modals, widgets |
| Z-index | `--z-dropdown``--z-toast` | Stacking hierarchy |
# Theme Architecture
**No arbitrary values.** Use tokens.
## 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:
## Typography
| Token | Font |
|---|---|
| `--font-sans` | Inter |
| `--font-mono` | JetBrains Mono |
Typography is fully tokenized:
## Tailwind Integration
Tokens exposed via `@theme`:
```css
--text-h1
--text-base
--text-sm
bg-bg-surface --bg-surface
text-text-h --text-h
border-border-default --border-default
```
---
# Spacing System
Consistent layout rhythm:
## Dark Mode
```css
--space-card-p
--space-flow
--space-label-gap
.dark { /* overrides root variables */ }
```
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.
Components should not implement separate dark styles unless absolutely necessary.
---
@@ -418,88 +433,90 @@ Theme switching only swaps root variables.
## Components
Components SHOULD:
| ✅ Do | ❌ Don't |
|---|---|
| Stay presentation-focused | Import the API client |
| Receive data via props/stores | Call `fetch()` |
| Emit actions upward | Own business logic |
| Remain reusable | Normalize backend payloads |
- remain presentation-focused
- receive data via props/stores
- emit actions upward
## Views
Components SHOULD NOT:
- perform complex business logic
- own global state
- duplicate API calls
---
| ✅ Do | ❌ Don't |
|---|---|
| Own ephemeral UI state | Perform backend mutations |
| Delegate to stores | Duplicate rollback logic |
| Orchestrate layout | Normalize API responses |
## Stores
Stores are the single source of truth.
Stores:
- own async requests
- normalize backend data
- handle websocket mutations
- expose reactive state
---
| ✅ Do | ❌ Don't |
|---|---|
| Own all backend mutations | Use raw `fetch()` |
| Perform optimistic updates | Set `isAuthenticated` during `login()` |
| Rollback on failure | Reassign reactive arrays |
| Normalize at boundary | Let components normalize |
## Services
Services must remain framework-agnostic.
They should not:
- import Vue
- import Pinia
- mutate UI directly
| ✅ Do | ❌ Don't |
|---|---|
| Stay framework-agnostic | Import Vue or Pinia |
| Own socket lifecycle | Mutate UI state directly |
| Emit parsed payloads | Own business logic |
---
# Debugging Rules
# Debugging Guide
## Blank Dashboard After Login
The #1 cause: **authenticated shell revealed before stores populated.**
Check:
1. Did `loadAppData()` actually run?
2. Is `isAuthenticated` set **after** data loads?
3. Did Login emit before unmounting?
4. Did `login()` avoid setting `isAuthenticated` internally?
## UI Not Updating
Check:
1. component action call
2. store mutation
3. reactive dependency
4. watcher trigger
---
1. Reactive dependency — is the component actually tracking the right ref?
2. Store mutation — did the mutation preserve array identity?
3. Watcher trigger — is the watcher source a valid ref/getter?
4. Computed dependency — are all inputs reactive?
## Realtime Issues
Check:
1. WS connection status — is it connected?
2. Reconnect flow — did it refetch?
3. App.vue routing — is the event mapped to the right handler?
4. Active filters — is the event filtered out by `node_id + sensor_id`?
1. WS connection status
2. event routing in App.vue
3. store WS handler
4. active filters
## Optimistic Update Bugs
---
1. Rollback path — does it restore the exact previous state?
2. Mutation symmetry — does undo match the original mutation?
3. Array identity — did you use `splice`/`Object.assign`, not reassignment?
## Sensor Selection Bugs
Always verify:
> Always verify composite key: `node_id + sensor_id`
```text
node_id + sensor_id
```
Selection/filtering logic must use composite keys.
Never filter by `sensor_id` alone — cross-node collisions will produce wrong results.
---
# Frontend Philosophy
# Architecture Philosophy
HoneyWire prioritizes:
- deterministic state flow
- centralized business logic
- reusable primitives
- strict visual consistency
- zero duplicated network logic
- scalable component composition
- **Deterministic rendering** — same state, same output
- **Stable reactive identity** — no array reassignment, no broken watchers
- **Centralized state ownership** — stores are the truth
- **Optimistic responsiveness** — instant UI, background sync
- **Rollback safety** — every mutation has an undo
- **Strict layering** — no upward dependency
- **Transport abstraction** — swap API client without touching stores
- **Predictable data flow** — one direction, one owner
+3
View File
@@ -39,11 +39,14 @@ func (h *Handler) CreateNode(w http.ResponseWriter, r *http.Request) {
return
}
h.broadcastWS("NEW_NODE", map[string]string{"node_id": nodeID})
SendJSON(w, http.StatusCreated, map[string]interface{}{
"node_id": nodeID,
"apiKey": apiKey,
"alias": req.Alias,
})
}
// UpdateNode handles UI requests to edit a Node's metadata
+99 -91
View File
@@ -1,8 +1,7 @@
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { onMounted, onUnmounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
// Components
import Sidebar from './components/layout/Sidebar.vue'
import Header from './components/layout/Header.vue'
import Dashboard from './views/Dashboard.vue'
@@ -13,14 +12,11 @@ import Store from './views/Store.vue'
import Settings from './views/Settings.vue'
import Setup from './views/Setup.vue'
// Services & Stores
import { useConfig } from './api/useConfig'
import { useAppStore } from './stores/app'
import { useFleetStore } from './stores/fleet'
import { useEventsStore } from './stores/events'
import { HoneyWireWS } from './services/ws'
import { HoneyWireWS } from './services/ws'
const { fetchConfig } = useConfig()
const appStore = useAppStore()
@@ -29,7 +25,7 @@ const eventsStore = useEventsStore()
const { currentView, viewingArchive } = storeToRefs(appStore)
watch([viewingArchive, () => fleetStore.selectedNode, () => fleetStore.selectedSensor],
watch([viewingArchive, () => fleetStore.selectedNode, () => fleetStore.selectedSensor],
([isArchived, node, sensor]) => {
eventsStore.fetchEvents(isArchived, node, sensor)
})
@@ -38,111 +34,123 @@ watch(() => fleetStore.activeTimeframe, (newTimeframe) => {
fleetStore.fetchUptime(newTimeframe)
})
const requiresSetup = ref(false)
const isAuthenticated = ref(false)
const wsService = new HoneyWireWS()
const checkAuthAndInit = async () => {
// ----------------------------------------------------
// TODO DEBUG OVERRIDE:
// Access http://localhost:8080/?debug=setup to force UI rendering
// ----------------------------------------------------
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('debug') === 'setup') {
requiresSetup.value = true;
isAuthenticated.value = false;
return;
}
const loadAppData = async () => {
try {
await fetchConfig()
await appStore.checkSetupStatus()
try {
const setupRes = await fetch('/api/v1/setup/status')
if (setupRes.ok) {
const setupData = await setupRes.json()
if (setupData.requires_setup) {
requiresSetup.value = true
isAuthenticated.value = false
return
}
}
requiresSetup.value = false
await Promise.all([
fleetStore.fetchFleet(),
fleetStore.fetchUptime(fleetStore.activeTimeframe),
eventsStore.fetchEvents(),
])
const res = await fetch('/api/v1/system/state')
if (res.ok) {
isAuthenticated.value = true
await fetchConfig()
await Promise.all([
fleetStore.fetchFleet(),
fleetStore.fetchUptime(fleetStore.activeTimeframe),
eventsStore.fetchEvents()
])
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('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))
// SMART RECOVERY: Only fetch data if the socket drops and reconnects
wsService.on('onReconnect', async () => {
console.log("WebSocket Reconnected: Syncing missed data...")
await Promise.all([
fleetStore.fetchFleet(),
fleetStore.fetchUptime(fleetStore.activeTimeframe),
eventsStore.fetchEvents()
])
})
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)
})
wsService.on('onSyncCharts', () => {
fleetStore.fetchUptime(fleetStore.activeTimeframe)
})
wsService.connect()
wsService.connect()
} else {
isAuthenticated.value = false
}
} catch (e) {
console.error("Hub connection error:", e)
isAuthenticated.value = false
}
appStore.isAuthenticated = true
appStore.isInitialized = true
} catch (e) {
console.error("Failed to load application data:", e)
appStore.isAuthenticated = true
appStore.isInitialized = true
}
}
const toggleTheme = () => {
const html = document.documentElement
if (html.classList.contains('dark')) {
html.classList.remove('dark')
localStorage.setItem('theme', 'light')
} else {
html.classList.add('dark')
localStorage.setItem('theme', 'dark')
const checkAuthAndInit = async () => {
const urlParams = new URLSearchParams(window.location.search)
if (urlParams.get('debug') === 'setup') {
appStore.requiresSetup = true
appStore.isAuthenticated = false
return
}
try {
const needsSetup = await appStore.checkRequiresSetup()
if (needsSetup) {
appStore.requiresSetup = true
appStore.isAuthenticated = false
return
}
appStore.requiresSetup = false
const authenticated = await appStore.checkSystemState()
if (!authenticated) {
appStore.isAuthenticated = false
return
}
await loadAppData()
} catch (e) {
console.error("Hub connection error:", e)
appStore.isAuthenticated = false
}
}
const onLoginSuccess = async () => {
appStore.requiresSetup = false
await loadAppData()
}
const onSetupComplete = async () => {
appStore.requiresSetup = false
await loadAppData()
}
const toggleTheme = () => {
const html = document.documentElement
if (html.classList.contains('dark')) {
html.classList.remove('dark')
localStorage.setItem('theme', 'light')
} else {
html.classList.add('dark')
localStorage.setItem('theme', 'dark')
}
}
onMounted(() => {
checkAuthAndInit()
checkAuthAndInit()
})
onUnmounted(() => {
wsService.disconnect()
wsService.disconnect()
})
</script>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
}
</script>
<template>
<div v-if="requiresSetup" class="h-screen bg-bg">
<Setup @setup-complete="checkAuthAndInit" @toggle-theme="toggleTheme" />
<div v-if="appStore.requiresSetup" class="h-screen bg-bg">
<Setup @setup-complete="onSetupComplete" @toggle-theme="toggleTheme" />
</div>
<div v-else-if="!isAuthenticated" class="h-screen bg-bg">
<Login @login-success="checkAuthAndInit" @toggle-theme="toggleTheme" />
<div v-else-if="!appStore.isAuthenticated" class="h-screen bg-bg">
<Login @login-success="onLoginSuccess" @toggle-theme="toggleTheme" />
</div>
<div v-else class="flex h-screen overflow-hidden bg-bg text-text-h transition-colors duration-200">
+48
View File
@@ -0,0 +1,48 @@
// src/api/client.js
class ApiError extends Error {
constructor(message, status) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
const handleResponse = async (res) => {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new ApiError(text || `HTTP ${res.status}`, res.status)
}
return res
}
export const api = {
async get(url) {
return handleResponse(await fetch(url))
},
async post(url, body) {
return handleResponse(await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}))
},
async patch(url, body) {
return handleResponse(await fetch(url, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}))
},
async delete(url) {
return handleResponse(await fetch(url, { method: 'DELETE' }))
},
// For requests with custom headers (e.g. Authorization) or non-JSON bodies
async request(url, options = {}) {
return handleResponse(await fetch(url, options))
},
}
+26
View File
@@ -30,6 +30,10 @@ export class HoneyWireWS {
onSilenceSensor: null,
onReconnect: null,
onSyncCharts: null,
onNewNode: null,
onUpdateNode: null,
onDeleteNode: null,
onNodeSynced: null,
}
}
@@ -138,6 +142,28 @@ export class HoneyWireWS {
this.callbacks.onSilenceSensor(data.payload)
}
break
case 'NEW_NODE':
if (this.callbacks.onNewNode) {
this.callbacks.onNewNode(data.payload)
}
break
case 'UPDATE_NODE':
if (this.callbacks.onUpdateNode) {
this.callbacks.onUpdateNode(data.payload)
}
break
case 'DELETE_NODE':
if (this.callbacks.onDeleteNode) {
this.callbacks.onDeleteNode(data.payload)
}
break
case 'NODE_SYNCED':
if (this.callbacks.onNodeSynced) {
this.callbacks.onNodeSynced(data.payload)
}
break
default:
console.warn(`WebSocket: Unknown message type: ${data.type}`)
+125 -25
View File
@@ -1,12 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* App Store (Global UI State)
*
* Manages system-wide UI settings: armed state, theme, archive view, sidebar, and current view.
* Auth checking is handled in App.vue, not here.
*/
import { api } from '../api/client'
export const useAppStore = defineStore('app', () => {
// --- STATE ---
@@ -18,12 +12,13 @@ export const useAppStore = defineStore('app', () => {
const activeTimeframe = ref('24H')
const velocityTimeframe = ref('24H')
// --- ACTIONS ---
// Auth/setup state (new, not used by old components)
const isAuthenticated = ref(false)
const requiresSetup = ref(false)
const isInitialized = ref(false)
// --- ACTIONS: UI ---
/**
* Toggle the armed state of the system
* Makes a PATCH request to /api/v1/system/state
*/
const toggleArmed = async () => {
const next = !isArmed.value
const previous = isArmed.value
@@ -43,9 +38,6 @@ export const useAppStore = defineStore('app', () => {
}
}
/**
* Toggle between light and dark theme
*/
const toggleTheme = () => {
const html = document.documentElement
if (html.classList.contains('dark')) {
@@ -57,10 +49,26 @@ export const useAppStore = defineStore('app', () => {
}
}
/**
* Logout and redirect to login page
* Makes a POST request to /logout
*/
const toggleSidebar = () => { sidebarOpen.value = !sidebarOpen.value }
const setView = (view) => { currentView.value = view }
const toggleArchive = () => { viewingArchive.value = !viewingArchive.value }
// --- ACTIONS: AUTH ---
const login = async (password) => {
try {
await api.post('/login', { password })
// Do NOT set isAuthenticated here.
// App.vue controls when to reveal the authenticated shell
// (after loadAppData completes), so the Login component
// stays mounted long enough to emit 'login-success'.
return { success: true }
} catch (err) {
isAuthenticated.value = false
return { success: false, status: err.status || 0 }
}
}
const logout = async () => {
try {
await fetch('/logout', { method: 'POST' })
@@ -70,9 +78,58 @@ export const useAppStore = defineStore('app', () => {
}
}
/**
* Fetch system version and state from backend (called during init)
*/
// --- ACTIONS: SETUP ---
const completeSetup = async (password, hubEndpoint, hubKey) => {
try {
await api.post('/api/v1/setup', {
password,
hub_endpoint: hubEndpoint,
hub_key: hubKey,
})
requiresSetup.value = false
isAuthenticated.value = true
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
// --- ACTIONS: SECURITY ---
const changePassword = async (currentPassword, newPassword) => {
try {
await api.patch('/api/v1/system/password', {
current_password: currentPassword,
new_password: newPassword,
})
return { success: true }
} catch (err) {
if (err.status === 401) {
return { success: false, error: 'Incorrect current password.' }
}
return { success: false, error: err.message || 'Failed to update password.' }
}
}
const factoryReset = async (password) => {
try {
await api.request('/api/v1/system/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
return { success: true }
} catch (err) {
if (err.status === 401) {
return { success: false, error: 'Incorrect password.' }
}
return { success: false, error: err.message || 'Factory reset failed.' }
}
}
// --- ACTIONS: BOOTSTRAP ---
const checkSetupStatus = async () => {
try {
const [stateRes, verRes] = await Promise.all([
@@ -87,8 +144,28 @@ export const useAppStore = defineStore('app', () => {
}
}
const checkSystemState = async () => {
try {
await api.get('/api/v1/system/state')
return true
} catch (e) {
return false
}
}
const checkRequiresSetup = async () => {
try {
const res = await api.get('/api/v1/setup/status')
const data = await res.json()
return data.requires_setup || false
} catch (e) {
console.error('Failed to check setup status:', e)
return false
}
}
return {
// State
// State — ALL original properties preserved
isArmed,
version,
viewingArchive,
@@ -96,10 +173,33 @@ export const useAppStore = defineStore('app', () => {
currentView,
activeTimeframe,
velocityTimeframe,
// Actions
// Auth/setup state (new)
isAuthenticated,
requiresSetup,
isInitialized,
// Actions: UI (original signatures preserved)
toggleArmed,
toggleTheme,
toggleSidebar,
setView,
toggleArchive,
// Actions: auth (new)
login,
logout,
// Actions: setup (new)
completeSetup,
// Actions: security (new)
changePassword,
factoryReset,
// Actions: bootstrap (original + new)
checkSetupStatus,
checkSystemState,
checkRequiresSetup,
}
})
})
+444 -79
View File
@@ -1,15 +1,112 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../api/client'
export const useFleetStore = defineStore('fleet', () => {
// --- STATE ---
const nodes = ref([]) // V2: Sensors are nested inside nodes!
const nodes = ref([])
const uptimeData = ref([])
const selectedNode = ref(null)
const selectedSensor = ref(null)
const activeTimeframe = ref('24H')
// --- NORMALIZATION ---
const normalizeSensor = (raw) => {
if (!raw) return null
return {
...raw,
id: raw.id || raw.sensor_id || raw.name,
name: raw.name || raw.sensor_id || raw.id,
display: raw.display || raw.custom_name || raw.name || raw.sensor_id || raw.id,
status: raw.status || 'down',
events24h: raw.events24h ?? raw.events_24h ?? 0,
isSilenced: raw.isSilenced ?? raw.is_silenced ?? false,
osi: raw.osi_layer || raw.osi || 'Sensor',
icon: raw.icon || raw.icon_svg || 'M12 12h0',
envVars: raw.envVars || raw.env_vars || {},
metadata: raw.metadata || {},
lastHeartbeat: raw.lastHeartbeat || raw.last_heartbeat || null,
}
}
const normalizeNode = (raw) => {
if (!raw) return null
return {
...raw,
id: raw.id,
alias: raw.alias || 'Unnamed Node',
status: raw.status || 'unknown',
publicIp: raw.publicIp || raw.public_ip || null,
privateIp: raw.privateIp || raw.private_ip || null,
tags: raw.tags || [],
apiKey: raw.apiKey || raw.api_key || null,
hasPendingConfig: raw.hasPendingConfig ?? raw.pending_config ?? raw.has_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),
}
}
// --- MERGE HELPERS ---
const mergeSensor = (existing, incoming) => {
Object.assign(existing, incoming)
}
const mergeNode = (existing, incoming) => {
const incomingSensors = incoming.installedSensors || []
const existingSensors = existing.installedSensors || []
const incomingSensorIds = new Set(incomingSensors.map(s => s.id))
incomingSensors.forEach(newSensor => {
const existingSensor = existingSensors.find(s => s.id === newSensor.id)
if (existingSensor) {
mergeSensor(existingSensor, newSensor)
} else {
existingSensors.push(newSensor)
}
})
for (let i = existingSensors.length - 1; i >= 0; i--) {
if (!incomingSensorIds.has(existingSensors[i].id)) {
existingSensors.splice(i, 1)
}
}
Object.assign(existing, incoming, { installedSensors: existingSensors })
}
// --- INDEXED ACCESS ---
const nodeMap = computed(() => {
const map = {}
for (const node of nodes.value) {
map[node.id] = node
}
return map
})
const sensorIndex = computed(() => {
const map = {}
for (const node of nodes.value) {
const sensors = {}
for (const sensor of (node.installedSensors || [])) {
sensors[sensor.id] = sensor
}
map[node.id] = sensors
}
return map
})
const getNode = (nodeId) => nodeMap.value[nodeId] || null
const getSensor = (nodeId, sensorId) => sensorIndex.value[nodeId]?.[sensorId] || null
// --- GETTERS ---
const overallUptime = computed(() => {
if (!uptimeData.value || uptimeData.value.length === 0) return '0.0%'
let validBlocks = 0
@@ -27,148 +124,416 @@ export const useFleetStore = defineStore('fleet', () => {
return validBlocks === 0 ? '100.0%' : ((upBlocks / validBlocks) * 100).toFixed(1) + '%'
})
// --- ACTIONS ---
// --- ACTIONS: FETCH ---
const fetchFleet = async () => {
try {
const res = await fetch('/api/v1/nodes')
if (res.ok) {
nodes.value = await res.json() || []
const res = await api.get('/api/v1/nodes')
const raw = await res.json() || []
const incoming = (Array.isArray(raw) ? raw : raw.nodes || []).map(normalizeNode)
const incomingIds = new Set(incoming.map(n => n.id))
incoming.forEach(newNode => {
const existing = getNode(newNode.id)
if (existing) {
mergeNode(existing, newNode)
} else {
nodes.value.push(newNode)
}
})
for (let i = nodes.value.length - 1; i >= 0; i--) {
if (!incomingIds.has(nodes.value[i].id)) {
nodes.value.splice(i, 1)
}
}
} catch (e) {
console.error('Failed to fetch fleet data', e)
}
}
const fetchUptime = async (timeframe) => {
const target = timeframe || activeTimeframe.value;
const fetchNodeDetails = async (nodeId) => {
try {
const res = await fetch(`/api/v1/uptime?timeframe=${target}`)
if (res.ok) uptimeData.value = await res.json() || []
const res = await api.get(`/api/v1/nodes/${encodeURIComponent(nodeId)}`)
const raw = await res.json()
const normalized = normalizeNode(raw)
const existing = getNode(nodeId)
if (existing) {
mergeNode(existing, normalized)
return existing
} else {
nodes.value.push(normalized)
return normalized
}
} catch (e) {
console.error('Failed to fetch uptime', e)
console.error('Failed to fetch node details:', e)
return null
}
}
const fetchUptime = async (timeframe) => {
const target = timeframe || activeTimeframe.value
try {
const res = await api.get(`/api/v1/uptime?timeframe=${target}`)
uptimeData.value = await res.json() || []
} catch (e) {
console.error('Failed to fetch uptime', e)
}
}
const fetchManifests = async () => {
try {
const res = await api.get('/api/v1/manifests')
return await res.json()
} catch (err) {
console.error('Failed to fetch manifests:', err)
throw err
}
}
// --- ACTIONS: SELECTION ---
const clearSelection = () => {
selectedNode.value = null
selectedSensor.value = null
}
const selectTarget = (nodeId, sensorId = null) => {
if (selectedNode.value === nodeId && selectedSensor.value === sensorId) {
selectedNode.value = null
selectedSensor.value = null
} else if (sensorId === null && selectedNode.value === nodeId && selectedSensor.value !== null) {
selectedSensor.value = null
} else if (sensorId === null && selectedNode.value === nodeId && selectedSensor.value === null) {
selectedNode.value = null
} else {
selectedNode.value = nodeId
selectedSensor.value = sensorId
const sameNode = selectedNode.value === nodeId
const sameSensor = selectedSensor.value === sensorId
if (sameNode && sameSensor) {
clearSelection()
return
}
selectedNode.value = nodeId
selectedSensor.value = sensorId
}
// --- ACTIONS: NODE MUTATIONS ---
const createNode = async (alias, tags = []) => {
try {
const res = await api.post('/api/v1/nodes', { alias, tags })
const data = await res.json()
// Optimistic add — partial node, background fetch fills details
const partialNode = normalizeNode({
id: data.node_id || data.nodeId,
alias,
tags,
status: 'pending',
apiKey: data.api_key || data.apiKey,
installedSensors: [],
})
nodes.value.push(partialNode)
// Background refresh for full details
fetchNodeDetails(partialNode.id)
return {
nodeId: data.node_id || data.nodeId,
apiKey: data.api_key || data.apiKey,
}
} catch (err) {
console.error('Failed to create node:', err)
throw err
}
}
const deleteNode = async (nodeId) => {
if (!confirm(`Delete Node "${nodeId}" and ALL of its underlying sensors?`)) return
const previousIdx = nodes.value.findIndex(n => n.id === nodeId)
const previous = previousIdx !== -1 ? nodes.value[previousIdx] : null
if (previousIdx !== -1) nodes.value.splice(previousIdx, 1)
if (selectedNode.value === nodeId) clearSelection()
try {
const res = await fetch(`/api/v1/nodes/${nodeId}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
if (selectedNode.value === nodeId) {
selectedNode.value = null
selectedSensor.value = null
}
await fetchFleet()
await api.delete(`/api/v1/nodes/${nodeId}`)
} catch (err) {
console.error('Failed to delete node:', err)
if (previous && previousIdx !== -1) {
nodes.value.splice(previousIdx, 0, previous)
}
fetchFleet()
}
}
const deleteSensor = async (nodeId, sensorId) => {
if (!confirm('Remove this sensor? The node will be marked for deployment sync.')) return
const updateNode = async (nodeId, payload) => {
const node = getNode(nodeId)
if (!node) return
const previous = {
alias: node.alias,
tags: [...node.tags],
publicIp: node.publicIp,
privateIp: node.privateIp,
}
if (payload.alias !== undefined) node.alias = payload.alias
if (payload.tags !== undefined) node.tags = payload.tags
if (payload.publicIp !== undefined) node.publicIp = payload.publicIp
if (payload.privateIp !== undefined) node.privateIp = payload.privateIp
try {
const res = await fetch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
if (selectedSensor.value === sensorId) selectedSensor.value = null
await fetchFleet()
await api.patch(`/api/v1/nodes/${nodeId}`, payload)
} catch (err) {
console.error('Failed to delete sensor:', err)
Object.assign(node, previous)
console.error('Failed to update node:', err)
throw err
}
}
// --- ACTIONS: SENSOR MUTATIONS ---
const addSensor = async (nodeId, { sensorId, customName, configValues }) => {
const node = getNode(nodeId)
if (!node) return
const optimisticSensor = normalizeSensor({
id: sensorId,
name: customName || sensorId,
display: customName || sensorId,
status: 'pending',
events24h: 0,
isSilenced: false,
osi: 'Sensor',
icon: 'M12 12h0',
lastHeartbeat: null,
})
node.installedSensors = node.installedSensors || []
node.installedSensors.push(optimisticSensor)
node.hasPendingConfig = true
try {
await api.post(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors`, {
sensor_id: sensorId,
custom_name: customName || sensorId,
config_values: configValues,
})
fetchNodeDetails(nodeId)
} catch (err) {
const idx = node.installedSensors.findIndex(s => s.id === sensorId)
if (idx !== -1) node.installedSensors.splice(idx, 1)
node.hasPendingConfig = false
console.error('Failed to add sensor:', err)
throw err
}
}
const removeSensor = async (nodeId, sensorId) => {
if (!confirm('Remove this sensor? The node will be marked for deployment sync.')) return
const node = getNode(nodeId)
if (!node) return
const sensorIdx = node.installedSensors?.findIndex(s => s.id === sensorId) ?? -1
const previous = sensorIdx !== -1 ? { ...node.installedSensors[sensorIdx] } : null
if (sensorIdx !== -1) node.installedSensors.splice(sensorIdx, 1)
node.hasPendingConfig = true
if (selectedSensor.value === sensorId) selectedSensor.value = null
try {
await api.delete(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors/${encodeURIComponent(sensorId)}`)
} catch (err) {
if (previous && sensorIdx !== -1) {
node.installedSensors.splice(sensorIdx, 0, previous)
node.hasPendingConfig = false
}
console.error('Failed to remove sensor:', err)
throw err
}
}
const toggleSilence = async (nodeId, sensorId, targetState) => {
const sensor = getSensor(nodeId, sensorId)
if (!sensor) return
const previous = sensor.isSilenced
sensor.isSilenced = targetState
try {
const res = await fetch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}/silence`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_silenced: targetState }),
await api.patch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}/silence`, {
is_silenced: targetState,
})
if (!res.ok) throw new Error('Silence toggle failed')
await fetchFleet()
} catch (err) {
sensor.isSilenced = previous
console.error('Failed to toggle sensor silence:', err)
throw err
}
}
const silenceNode = async (nodeId) => {
const node = nodes.value.find(n => n.id === nodeId)
if (!node || !node.installedSensors || !node.installedSensors.length) return
const node = getNode(nodeId)
if (!node?.installedSensors?.length) return
const allSilenced = node.installedSensors.every(s => s.isSilenced)
const targetState = !allSilenced
const previousStates = node.installedSensors.map(s => s.isSilenced)
node.installedSensors.forEach(s => { s.isSilenced = targetState })
try {
await Promise.all(node.installedSensors.map(s =>
fetch(`/api/v1/nodes/${nodeId}/sensors/${s.id}/silence`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_silenced: targetState })
})
await Promise.all(node.installedSensors.map(s =>
api.patch(`/api/v1/nodes/${nodeId}/sensors/${s.id}/silence`, {
is_silenced: targetState,
})
))
await fetchFleet()
} catch (err) {
console.error("Failed to silence node:", err)
node.installedSensors.forEach((s, i) => { s.isSilenced = previousStates[i] })
console.error('Failed to silence node:', err)
throw err
}
}
// --- ACTIONS: COMPOSE / SYNC ---
const fetchCompose = async (apiKey) => {
try {
const res = await api.request('/api/v1/nodes/compose', {
headers: { Authorization: `Bearer ${apiKey}` },
})
return await res.text()
} catch (err) {
console.error('Failed to fetch compose:', err)
throw err
}
}
const generateCompose = async (payload) => {
try {
const res = await api.post('/api/v1/compose/generate', payload)
return await res.text()
} catch (err) {
console.error('Failed to generate compose:', err)
throw err
}
}
const syncNode = async (nodeId) => {
const node = getNode(nodeId)
if (!node?.apiKey) {
throw new Error('Unable to sync: missing node API key')
}
const composeYaml = await fetchCompose(node.apiKey)
node.hasPendingConfig = false
fetchNodeDetails(nodeId)
return composeYaml
}
// --- WEBSOCKET HANDLER ---
const handleWsUpdate = (type, payload) => {
// For V2, the safest and cleanest way to handle state changes (since sensors are nested)
// is to just refetch the fleet on critical events, or do targeted updates.
if (['NODE_SYNCED', 'UPDATE_NODE', 'DELETE_NODE', 'SILENCE_SENSOR'].includes(type)) {
fetchFleet()
} else if (type === 'NEW_EVENT') {
fetchFleet() // Refetches to update the 24h event counts
} else if (type === 'SENSOR_HEARTBEAT') {
const node = nodes.value.find(n => n.id === payload.node_id)
if (type === 'SENSOR_HEARTBEAT') {
const node = getNode(payload.node_id)
if (node) {
node.lastHeartbeat = payload.timestamp
node.status = 'up'
const sensor = (node.installedSensors || []).find(s => s.id === payload.sensor_id)
const sensor = getSensor(payload.node_id, payload.sensor_id)
if (sensor) {
sensor.lastHeartbeat = payload.timestamp
sensor.status = 'up'
}
}
return
}
}
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
if (type === 'SILENCE_SENSOR') {
const sensor = getSensor(payload.node_id, payload.sensor_id)
if (sensor) {
sensor.isSilenced = payload.is_silenced ?? payload.isSilenced ?? sensor.isSilenced
}
return
}
if (type === 'NEW_SENSOR') {
const node = getNode(payload.node_id)
if (node && payload.sensor) {
const sensorId = payload.sensor.id || payload.sensor.sensor_id
const exists = getSensor(payload.node_id, sensorId)
if (!exists) {
node.installedSensors = node.installedSensors || []
node.installedSensors.push(normalizeSensor(payload.sensor))
}
node.hasPendingConfig = true
}
return
}
if (type === 'DELETE_SENSOR') {
const node = getNode(payload.node_id)
if (node && payload.sensor_id) {
const idx = node.installedSensors?.findIndex(s => s.id === payload.sensor_id) ?? -1
if (idx !== -1) node.installedSensors.splice(idx, 1)
node.hasPendingConfig = true
}
return
}
if (type === 'NODE_SYNCED') {
const node = getNode(payload.node_id)
if (node) {
node.hasPendingConfig = false
if (payload.active_revision) node.activeRevision = payload.active_revision
if (payload.desired_revision !== undefined) node.desiredRevision = payload.desired_revision
}
return
}
if (type === 'UPDATE_NODE') {
const node = getNode(payload.id)
if (node) mergeNode(node, normalizeNode(payload))
return
}
if (type === 'DELETE_NODE') {
const idx = nodes.value.findIndex(n => n.id === payload.node_id)
if (idx !== -1) nodes.value.splice(idx, 1)
if (selectedNode.value === payload.node_id) clearSelection()
return
}
if (type === 'NEW_NODE') {
const exists = getNode(payload.id)
if (!exists) nodes.value.push(normalizeNode(payload))
return
}
if (type === 'NEW_EVENT') {
if (payload.node_id && payload.sensor_id) {
const node = getNode(payload.node_id)
if (node) {
const sensor = getSensor(payload.node_id, payload.sensor_id)
if (sensor) sensor.events24h = (sensor.events24h || 0) + 1
node.lastEvent = 'Just now'
}
} else {
fetchFleet()
}
return
}
}
return {
nodes, uptimeData, selectedNode, selectedSensor, activeTimeframe,
overallUptime,
fetchFleet, fetchUptime, selectTarget, deleteNode, deleteSensor, toggleSilence, silenceNode, handleWsUpdate,
updateNode,
overallUptime, nodeMap, sensorIndex,
getNode, getSensor,
fetchFleet, fetchNodeDetails, fetchUptime, fetchManifests,
selectTarget, clearSelection,
createNode, deleteNode, updateNode,
addSensor, removeSensor, toggleSilence, silenceNode,
fetchCompose, generateCompose, syncNode,
handleWsUpdate,
normalizeSensor, normalizeNode,
}
})
+4 -20
View File
@@ -16,37 +16,21 @@ const currentHost = ref(window.location.host)
const generateToken = async () => {
const alias = window.prompt('Enter a friendly alias for the new node', `New Node ${Date.now()}`)
if (!alias) {
return
}
if (!alias) return
isGeneratingToken.value = true
try {
const response = await fetch('/api/v1/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias, tags: [] }),
})
if (!response.ok) {
const errText = await response.text()
throw new Error(errText || 'Failed to create node')
}
const data = await response.json()
provisionToken.value = data.api_key
provisionNodeId.value = data.node_id
const result = await fleetStore.createNode(alias)
provisionToken.value = result.apiKey
provisionNodeId.value = result.nodeId
showProvisionModal.value = true
fleetStore.fetchFleet()
} catch (err) {
console.error('Create node error:', err)
alert('Failed to create node. Please try again.')
} finally {
isGeneratingToken.value = false
}
}
// Added Quick-Copy functionality for the provision command
const copyCommand = () => {
const cmd = `./wizard --link http://${currentHost.value} ${provisionToken.value}`
navigator.clipboard.writeText(cmd)
+33 -81
View File
@@ -1,6 +1,5 @@
<script setup>
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'
@@ -18,24 +17,24 @@ onMounted(() => {
fleetStore.fetchFleet()
})
// --- DEPLOY MODAL STATE ---
// --- DEPLOY MODAL STATE (ephemeral UI) ---
const showDeployModal = ref(false)
const deployStep = ref(1)
const deployStep = ref(1)
const newNodeForm = ref({ alias: '' })
const editingTagNodeId = ref(null)
const newTagValue = ref('')
const tagInputRefs = ref({}) // Dictionary to hold refs for multiple nodes
const generatedNodeKey = ref('')
// --- EDIT MODAL STATE ---
// --- EDIT MODAL STATE (ephemeral UI) ---
const showEditModal = ref(false)
const editNodeForm = ref({ id: '', alias: '', publicIp: '', privateIp: '', apiKey: '' })
// --- TAGS LOGIC ---
// --- TAGS LOGIC (ephemeral UI + store delegation) ---
const editingTagNodeId = ref(null)
const newTagValue = ref('')
const tagInputRefs = ref({})
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()
}
@@ -49,49 +48,36 @@ const cancelTag = () => {
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,
tags: [...node.tags, val],
publicIp: node.publicIp,
privateIp: node.privateIp
privateIp: node.privateIp,
})
} catch (err) {
// Rollback on failure
node.tags = originalTags
// Store handles rollback
}
}
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
privateIp: node.privateIp,
})
} catch (err) {
// Rollback on failure
node.tags = originalTags
// Store handles rollback
}
}
// --- DATA MAPPING ---
// --- DATA MAPPING (presentation transform — belongs in view) ---
const displayNodes = computed(() => {
return fleetStore.nodes.map(node => {
const sensorsList = node.installedSensors || []
@@ -99,7 +85,6 @@ const displayNodes = computed(() => {
const totalSensors = sensorsList.length
const isSilenced = totalSensors > 0 && sensorsList.every(s => s.isSilenced)
// Grouping for the UI tooltip
const sensorSummary = totalSensors > 0
? [{ type: 'Deployed', count: totalSensors, sensors: sensorsList.map(s => ({ name: s.display || s.name, status: s.status })) }]
: []
@@ -111,40 +96,30 @@ const displayNodes = computed(() => {
isSilenced,
sensorSummary,
hasUpdate: false,
// A node is pending if it has no sensors or has explicitly just been created
isAwaitingCheckIn: node.status === 'pending' || (!node.lastHeartbeat && totalSensors === 0)
}
})
})
// --- API ACTIONS ---
// --- ACTIONS (all delegated to store) ---
const handleDeploySubmit = async () => {
if (!newNodeForm.value.alias) return
try {
const response = await fetch('/api/v1/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alias: newNodeForm.value.alias}),
})
if (!response.ok) throw new Error('Failed to create node')
const data = await response.json()
generatedNodeKey.value = data.apiKey
const result = await fleetStore.createNode(newNodeForm.value.alias)
generatedNodeKey.value = result.apiKey
deployStep.value = 2
await fleetStore.fetchFleet()
} catch (err) {
console.error('Create node failed:', err)
alert('Could not create node. Please try again.')
}
}
const handleOpenEditNode = (node) => {
editNodeForm.value = {
id: node.id,
alias: node.alias,
publicIp: node.publicIp || '',
editNodeForm.value = {
id: node.id,
alias: node.alias,
publicIp: node.publicIp || '',
privateIp: node.privateIp || '',
apiKey: node.apiKey || ''
}
@@ -152,46 +127,25 @@ const handleOpenEditNode = (node) => {
}
const triggerManualSync = async (node) => {
if (!node) return
const apiKey = node.apiKey
if (!apiKey) {
alert('Unable to sync this node because the node API key is missing.')
return
}
if (!node?.id) return
try {
const response = await fetch('/api/v1/nodes/compose', {
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`)
}
await fleetStore.fetchFleet()
alert('Pending sync request sent successfully. The node compose was generated and pending config has been updated.')
await fleetStore.syncNode(node.id)
alert('Pending sync request sent successfully.')
} catch (err) {
console.error('Failed to sync node:', err)
alert('Unable to sync this node. Please try again.')
}
}
const handleEditSubmit = async () => {
try {
const response = await fetch(`/api/v1/nodes/${editNodeForm.value.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
alias: editNodeForm.value.alias,
publicIp: editNodeForm.value.publicIp,
privateIp: editNodeForm.value.privateIp
}),
await fleetStore.updateNode(editNodeForm.value.id, {
alias: editNodeForm.value.alias,
publicIp: editNodeForm.value.publicIp,
privateIp: editNodeForm.value.privateIp,
})
if (!response.ok) throw new Error('Failed to update node')
showEditModal.value = false
await fleetStore.fetchFleet()
} catch (err) {
console.error('Update node failed:', err)
// Store handles rollback
}
}
@@ -209,10 +163,10 @@ const handleForgetNode = (nodeId) => fleetStore.deleteNode(nodeId)
const handleOpenNodeDetail = (nodeId) => {
fleetStore.selectTarget(nodeId)
appStore.currentView = 'node-detail'
appStore.setView('node-detail')
}
// --- Copy Animation Logic ---
// --- Copy Animation (ephemeral UI) ---
const copiedStates = ref({})
const handleCopy = async (id, text) => {
@@ -220,9 +174,7 @@ const handleCopy = async (id, text) => {
try {
await navigator.clipboard.writeText(text)
copiedStates.value[id] = true
setTimeout(() => {
copiedStates.value[id] = false
}, 2000)
setTimeout(() => { copiedStates.value[id] = false }, 2000)
} catch (err) {
console.error('Failed to copy text', err)
}
+14 -19
View File
@@ -1,5 +1,6 @@
<script setup>
import { ref } from 'vue'
import { useAppStore } from '../stores/app'
import BaseInput from '../components/ui/forms/BaseInput.vue'
import BaseButton from '../components/ui/forms/BaseButton.vue'
import BaseCard from '../components/ui/layout/BaseCard.vue'
@@ -9,6 +10,8 @@ import ThemeToggle from '../components/ui/branding/ThemeToggle.vue'
import BaseLogo from '../components/ui/branding/BaseLogo.vue'
const emit = defineEmits(['login-success', 'toggle-theme'])
const appStore = useAppStore()
const password = ref('')
const loading = ref(false)
const error = ref(false)
@@ -19,27 +22,19 @@ const doLogin = async () => {
error.value = false
rateLimited.value = false
try {
const res = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ password: password.value })
})
if (res.ok) {
emit('login-success')
} else if (res.status === 429) {
rateLimited.value = true
password.value = ''
} else {
error.value = true
password.value = ''
}
} catch (err) {
const result = await appStore.login(password.value)
if (result.success) {
emit('login-success')
} else if (result.status === 429) {
rateLimited.value = true
password.value = ''
} else {
error.value = true
} finally {
loading.value = false
password.value = ''
}
loading.value = false
}
</script>
+265 -350
View File
@@ -17,34 +17,27 @@ const { config } = useConfig()
const selectedNodeId = computed(() => fleetStore.selectedNode)
// --- 1. NODE STATE ---
const node = ref({
id: null,
alias: 'Loading node...',
status: 'unknown',
publicIp: null,
privateIp: null,
apiKey: null,
tags: [],
hasPendingConfig: false,
lastEvent: 'Unknown',
installedSensors: [],
})
// --- NODE STATE ---
// Single source of truth: the live reactive object from the store.
const node = computed(() => fleetStore.getNode(selectedNodeId.value))
const maxSensorEvents = computed(() => {
if (!node.value.installedSensors.length) return 1
return Math.max(...node.value.installedSensors.map(s => s.events24h || 0), 1)
if (!node.value?.installedSensors?.length) return 1
return Math.max(...node.value.installedSensors.map(s => s.events24h || 0), 1)
})
const sortedInstalledSensors = computed(() => {
return [...(node.value.installedSensors || [])].sort((a, b) => (b.events24h || 0) - (a.events24h || 0))
return [...(node.value?.installedSensors || [])]
.sort((a, b) => (b.events24h || 0) - (a.events24h || 0))
})
const isLoading = ref(true)
// --- SENSOR CATALOG STATE ---
const isManifestLoading = ref(true)
const fetchError = ref(false)
const selectedSensor = ref(null)
const sensors = ref([])
// --- SENSOR MODAL STATE ---
const selectedSensor = ref(null)
const activeTab = ref('readme')
const envVarValues = ref({})
const activeEnvVar = ref(null)
@@ -52,365 +45,287 @@ const isSeverityOpen = ref(false)
const rawCompose = ref('')
const highlightedCompose = ref('')
const composePre = ref(null)
// --- MODAL STATE ---
const showKeyModal = ref(false)
const showSyncModal = ref(false)
const syncComposeYaml = ref('')
const severityOptions = [
{ value: 'info', label: 'Info', textClass: 'text-info', hoverClass: 'hover:bg-info/10 hover:text-info' },
{ value: 'low', label: 'Low', textClass: 'text-low', hoverClass: 'hover:bg-low/10 hover:text-low' },
{ value: 'medium', label: 'Medium', textClass: 'text-medium', hoverClass: 'hover:bg-medium/10 hover:text-medium' },
{ value: 'high', label: 'High', textClass: 'text-high', hoverClass: 'hover:bg-high/10 hover:text-high' },
{ value: 'critical', label: 'Critical', textClass: 'text-critical', hoverClass: 'hover:bg-critical/10 hover:text-critical' }
]
const currentSeverity = computed(() => severityOptions.find(s => s.value === envVarValues.value['HW_SEVERITY']) || severityOptions[3])
const toggleSeverity = () => { isSeverityOpen.value = !isSeverityOpen.value; activeEnvVar.value = isSeverityOpen.value ? 'HW_SEVERITY' : null }
const closeSeverity = () => { isSeverityOpen.value = false; activeEnvVar.value = null }
const selectSeverity = (val) => { envVarValues.value['HW_SEVERITY'] = val; closeSeverity() }
const getUIDefault = (def) => {
if (!def) return ''
if (!def.includes('{{')) return def
const elseMatch = def.match(/\{\{\s*else\s*\}\}(.*?)\{\{\s*end\s*\}\}/)
if (elseMatch) return elseMatch[1].trim()
const funcMatch = def.match(/\{\{\s*[a-zA-Z]+\s+([0-9]+)\s*\}\}/)
if (funcMatch) return funcMatch[1].trim()
return ''
}
const coreVars = ['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SENSOR_ID', 'HW_SEVERITY', 'HW_TEST_MODE', 'HW_LOG_LEVEL']
const sortedEnvVars = computed(() => {
if (!selectedSensor.value?.deployment?.env_vars) return []
return [...selectedSensor.value.deployment.env_vars]
.filter(env => !env.hidden)
.sort((a, b) => {
const aIsCore = coreVars.includes(a.name); const bIsCore = coreVars.includes(b.name)
if (aIsCore && !bIsCore) return -1
if (!aIsCore && bIsCore) return 1
if (aIsCore && bIsCore) return coreVars.indexOf(a.name) - coreVars.indexOf(b.name)
return a.name.localeCompare(b.name)
})
})
const mapNodeSensor = (sensor) => ({
id: sensor.sensor_id || sensor.id || sensor.name,
name: sensor.custom_name || sensor.name || sensor.sensor_id || sensor.id,
display: sensor.custom_name || sensor.name || sensor.sensor_id || sensor.id,
status: sensor.status || 'down',
events24h: sensor.events_24h || sensor.events24h || 0,
isSilenced: sensor.is_silenced || sensor.isSilenced || false,
osi: sensor.osi_layer || sensor.osi || 'Sensor',
icon: sensor.icon || sensor.icon_svg || 'M12 12h0',
...sensor,
})
const fetchNodeDetails = async () => {
if (!selectedNodeId.value) {
appStore.currentView = 'fleet'
return
}
isLoading.value = true
fetchError.value = false
try {
const response = await fetch(`/api/v1/nodes/${encodeURIComponent(selectedNodeId.value)}`, { cache: 'no-store' })
if (!response.ok) throw new Error(`Failed to fetch node: ${response.status}`)
const data = await response.json()
node.value = {
id: data.id || data.node_id || selectedNodeId.value,
alias: data.alias || data.name || 'Unknown Node',
status: data.status || 'unknown',
publicIp: data.public_ip || data.publicIp || null,
privateIp: data.private_ip || data.privateIp || null,
apiKey: data.apiKey || null,
tags: data.tags || [],
hasPendingConfig: data.has_pending_config || data.hasPendingConfig || false,
lastEvent: data.last_event || data.lastEvent || 'Unknown',
installedSensors: Array.isArray(data.installed_sensors || data.installedSensors)
? (data.installed_sensors || data.installedSensors).map(mapNodeSensor)
: [],
}
} catch (error) {
console.error('Failed to load node details', error)
fetchError.value = true
appStore.currentView = 'fleet'
} finally {
isLoading.value = false
}
}
const handleToggleSensorSilence = async (sensor) => {
if (!node.value.id || !sensor.id) return
const previous = sensor.isSilenced
sensor.isSilenced = !sensor.isSilenced
try {
const response = await fetch(`/api/v1/nodes/${encodeURIComponent(node.value.id)}/sensors/${encodeURIComponent(sensor.id)}/silence`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_silenced: sensor.isSilenced }),
})
if (!response.ok) throw new Error(`Server error: ${response.status}`)
} catch (err) {
sensor.isSilenced = previous
console.error('Failed to toggle silence:', err)
alert('Unable to change sensor silence state. Please try again.')
}
}
const handleRemoveSensor = async (sensor) => {
if (!node.value.id || !sensor.id) return
if (!confirm(`Delete sensor ${sensor.name || sensor.id} from ${node.value.alias}?`)) return
try {
const response = await fetch(`/api/v1/nodes/${encodeURIComponent(node.value.id)}/sensors/${encodeURIComponent(sensor.id)}`, {
method: 'DELETE',
})
if (!response.ok) throw new Error(`Server error: ${response.status}`)
node.value.installedSensors = node.value.installedSensors.filter(s => s.id !== sensor.id)
} catch (err) {
console.error('Failed to remove sensor:', err)
alert('Could not remove sensor. Please try again.')
}
}
const handleAddSensorToNode = async () => {
if (!selectedSensor.value || !node.value.id) return
const safeEnvValues = Object.fromEntries(Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : '']))
const payload = {
sensor_id: selectedSensor.value.id || selectedSensor.value.sensor_id || selectedSensor.value.name,
custom_name: selectedSensor.value.name || selectedSensor.value.id,
config_values: safeEnvValues,
}
try {
const response = await fetch(`/api/v1/nodes/${encodeURIComponent(node.value.id)}/sensors`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) throw new Error(`Server error: ${response.status}`)
await fetchNodeDetails()
closeSensor()
} catch (err) {
console.error('Failed to add sensor to node:', err)
alert('Could not add sensor to this node. Please try again.')
}
}
const handleManageKey = () => {
showKeyModal.value = true
}
const getNodeApiKey = () => node.value.apiKey || null
const copyNodeKeyToClipboard = async () => {
const apiKey = getNodeApiKey()
if (!apiKey) return
try {
await navigator.clipboard.writeText(apiKey)
alert('Node API Key copied to clipboard')
} catch (err) {
console.error('Failed to copy node API Key:', err)
alert('Unable to copy Node API Key. Please copy it manually.')
}
}
const copySyncYamlToClipboard = async () => {
if (!syncComposeYaml.value) return
try {
await navigator.clipboard.writeText(syncComposeYaml.value)
alert('Compose YAML copied to clipboard')
} catch (err) {
console.error('Failed to copy compose YAML:', err)
alert('Unable to copy compose YAML. Please copy it manually.')
}
}
const triggerManualSync = async () => {
const apiKey = getNodeApiKey()
if (!apiKey) {
alert('Unable to sync this node: missing node API key.')
return
}
try {
const response = await fetch('/api/v1/nodes/compose', {
headers: { Authorization: `Bearer ${apiKey}` },
})
if (!response.ok) {
throw new Error(`Node sync failed: ${response.status}`)
}
syncComposeYaml.value = await response.text()
showSyncModal.value = true
await fetchNodeDetails()
} catch (err) {
console.error('Failed to sync node:', err)
alert('Unable to sync this node. Please try again.')
}
}
onMounted(() => {
fetchNodeDetails()
eventsStore.fetchEvents(false, selectedNodeId.value)
})
watch(selectedNodeId, (value) => {
if (!value) {
appStore.currentView = 'fleet'
return
}
fetchNodeDetails()
eventsStore.fetchEvents(false, value)
})
const timeAgo = (dateStr) => {
if (!dateStr) return 'Unknown'
const diff = Math.floor((new Date() - new Date(dateStr)) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
const recentActivity = computed(() => {
return eventsStore.filteredEvents.slice(0, 10).map(e => ({
id: e.id,
time: timeAgo(e.timestamp),
severity: e.severity || 'info',
event_trigger: e.event_trigger || 'Alert',
source: e.source || 'Unknown'
}))
})
// --- EDIT MODAL STATE ---
const showEditModal = ref(false)
const editNodeForm = ref({ id: '', alias: '', publicIp: '', privateIp: '' })
const tagInput = ref('')
const tagsList = ref([])
// --- SEVERITY CONFIG ---
const severityOptions = [
{ value: 'info', label: 'Info', textClass: 'text-info', hoverClass: 'hover:bg-info/10 hover:text-info' },
{ value: 'low', label: 'Low', textClass: 'text-low', hoverClass: 'hover:bg-low/10 hover:text-low' },
{ value: 'medium', label: 'Medium', textClass: 'text-medium', hoverClass: 'hover:bg-medium/10 hover:text-medium' },
{ value: 'high', label: 'High', textClass: 'text-high', hoverClass: 'hover:bg-high/10 hover:text-high' },
{ value: 'critical', label: 'Critical', textClass: 'text-critical', hoverClass: 'hover:bg-critical/10 hover:text-critical' }
]
const currentSeverity = computed(() =>
severityOptions.find(s => s.value === envVarValues.value['HW_SEVERITY']) || severityOptions[3]
)
const toggleSeverity = () => {
isSeverityOpen.value = !isSeverityOpen.value
activeEnvVar.value = isSeverityOpen.value ? 'HW_SEVERITY' : null
}
const closeSeverity = () => { isSeverityOpen.value = false; activeEnvVar.value = null }
const selectSeverity = (val) => { envVarValues.value['HW_SEVERITY'] = val; closeSeverity() }
// --- ENV VAR HELPERS ---
const getUIDefault = (def) => {
if (!def) return ''
if (!def.includes('{{')) return def
const elseMatch = def.match(/\{\{\s*else\s*\}\}(.*?)\{\{\s*end\s*\}\}/)
if (elseMatch) return elseMatch[1].trim()
const funcMatch = def.match(/\{\{\s*[a-zA-Z]+\s+([0-9]+)\s*\}\}/)
if (funcMatch) return funcMatch[1].trim()
return ''
}
const coreVars = ['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SENSOR_ID', 'HW_SEVERITY', 'HW_TEST_MODE', 'HW_LOG_LEVEL']
const sortedEnvVars = computed(() => {
if (!selectedSensor.value?.deployment?.env_vars) return []
return [...selectedSensor.value.deployment.env_vars]
.filter(env => !env.hidden)
.sort((a, b) => {
const aIsCore = coreVars.includes(a.name)
const bIsCore = coreVars.includes(b.name)
if (aIsCore && !bIsCore) return -1
if (!aIsCore && bIsCore) return 1
if (aIsCore && bIsCore) return coreVars.indexOf(a.name) - coreVars.indexOf(b.name)
return a.name.localeCompare(b.name)
})
})
// --- SENSOR ACTIONS (delegated to store) ---
const handleToggleSensorSilence = async (sensor) => {
if (!node.value?.id || !sensor.id) return
try {
await fleetStore.toggleSilence(node.value.id, sensor.id, !sensor.isSilenced)
} catch (err) {
alert('Unable to change sensor silence state. Please try again.')
}
}
const handleRemoveSensor = async (sensor) => {
if (!node.value?.id || !sensor.id) return
try {
await fleetStore.removeSensor(node.value.id, sensor.id)
} catch (err) {
alert('Could not remove sensor. Please try again.')
}
}
const handleAddSensorToNode = async () => {
if (!selectedSensor.value || !node.value?.id) return
const safeEnvValues = Object.fromEntries(
Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : ''])
)
try {
await fleetStore.addSensor(node.value.id, {
sensorId: selectedSensor.value.id || selectedSensor.value.sensor_id || selectedSensor.value.name,
customName: selectedSensor.value.name || selectedSensor.value.id,
configValues: safeEnvValues,
})
closeSensor()
} catch (err) {
alert('Could not add sensor to this node. Please try again.')
}
}
// --- NODE ACTIONS (delegated to store) ---
const handleManageKey = () => { showKeyModal.value = true }
const copyNodeKeyToClipboard = async () => {
const apiKey = node.value?.apiKey
if (!apiKey) return
try {
await navigator.clipboard.writeText(apiKey)
alert('Node API Key copied to clipboard')
} catch (err) {
alert('Unable to copy Node API Key. Please copy it manually.')
}
}
const copySyncYamlToClipboard = async () => {
if (!syncComposeYaml.value) return
try {
await navigator.clipboard.writeText(syncComposeYaml.value)
alert('Compose YAML copied to clipboard')
} catch (err) {
alert('Unable to copy compose YAML. Please copy it manually.')
}
}
const triggerManualSync = async () => {
if (!node.value?.id) return
try {
syncComposeYaml.value = await fleetStore.syncNode(node.value.id)
showSyncModal.value = true
} catch (err) {
alert('Unable to sync this node. Please try again.')
}
}
// --- NAVIGATION ---
watch(selectedNodeId, async (value) => {
if (!value) {
appStore.currentView = 'fleet'
return
}
await fleetStore.fetchNodeDetails(value)
eventsStore.fetchEvents(false, value)
}, { immediate: true })
const timeAgo = (dateStr) => {
if (!dateStr) return 'Unknown'
const diff = Math.floor((new Date() - new Date(dateStr)) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
const recentActivity = computed(() => {
return eventsStore.filteredEvents.slice(0, 10).map(e => ({
id: e.id,
time: timeAgo(e.timestamp),
severity: e.severity || 'info',
event_trigger: e.event_trigger || 'Alert',
source: e.source || 'Unknown'
}))
})
// --- EDIT MODAL ---
const openEditModal = () => {
editNodeForm.value = {
id: node.value.id,
alias: node.value.alias,
publicIp: node.value.publicIp || '',
privateIp: node.value.privateIp || ''
}
tagsList.value = [...(node.value.tags || [])]
showEditModal.value = true
if (!node.value) return
editNodeForm.value = {
id: node.value.id,
alias: node.value.alias,
publicIp: node.value.publicIp || '',
privateIp: node.value.privateIp || ''
}
tagsList.value = [...(node.value.tags || [])]
showEditModal.value = true
}
const addTag = () => {
const val = tagInput.value.trim()
if (val && !tagsList.value.includes(val)) {
tagsList.value.push(val)
}
tagInput.value = ''
const val = tagInput.value.trim()
if (val && !tagsList.value.includes(val)) tagsList.value.push(val)
tagInput.value = ''
}
const removeTag = (index) => {
tagsList.value.splice(index, 1)
}
const removeTag = (index) => { tagsList.value.splice(index, 1) }
const handleEditSubmit = async () => {
try {
const response = await fetch(`/api/v1/nodes/${editNodeForm.value.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
alias: editNodeForm.value.alias,
tags: tagsList.value,
publicIp: editNodeForm.value.publicIp,
privateIp: editNodeForm.value.privateIp
}),
})
if (!response.ok) throw new Error('Failed to update node')
showEditModal.value = false
await fetchNodeDetails()
await fleetStore.fetchFleet()
} catch (err) {
console.error('Update node failed:', err)
}
try {
await fleetStore.updateNode(editNodeForm.value.id, {
alias: editNodeForm.value.alias,
tags: tagsList.value,
publicIp: editNodeForm.value.publicIp,
privateIp: editNodeForm.value.privateIp,
})
showEditModal.value = false
} catch (err) {
alert('Failed to update node. Please try again.')
}
}
watch(envVarValues, () => { fetchYamlFromHub(); }, { deep: true });
watch(activeEnvVar, () => { applyHighlighting(); });
// --- SENSOR CATALOG ---
watch(envVarValues, () => { fetchYamlFromHub() }, { deep: true })
watch(activeEnvVar, () => { applyHighlighting() })
onMounted(async () => {
try {
isLoading.value = true;
const response = await fetch("/api/v1/manifests", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP error`);
sensors.value = await response.json();
} catch (error) {
console.error(error); fetchError.value = true;
} finally {
isLoading.value = false;
}
});
isManifestLoading.value = true
try {
sensors.value = await fleetStore.fetchManifests()
} catch (error) {
console.error(error)
fetchError.value = true
} finally {
isManifestLoading.value = false
}
})
const openSensor = (sensor) => {
const apiKey = getNodeApiKey()
selectedSensor.value = sensor; activeTab.value = 'readme'; envVarValues.value = {};
envVarValues.value['HW_SEVERITY'] = 'critical';
envVarValues.value['HW_HUB_ENDPOINT'] = config.hubEndpoint || window.location.origin;
envVarValues.value['HW_HUB_KEY'] = apiKey || '<YOUR_HW_NODE_KEY>';
sensor.deployment?.env_vars?.forEach(env => {
if (!['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SEVERITY'].includes(env.name)) {
envVarValues.value[env.name] = getUIDefault(env.default);
}
})
document.body.style.overflow = 'hidden';
fetchYamlFromHub();
const apiKey = node.value?.apiKey
selectedSensor.value = sensor
activeTab.value = 'readme'
envVarValues.value = {}
envVarValues.value['HW_SEVERITY'] = 'critical'
envVarValues.value['HW_HUB_ENDPOINT'] = config.hubEndpoint || window.location.origin
envVarValues.value['HW_HUB_KEY'] = apiKey || '<YOUR_HW_NODE_KEY>'
sensor.deployment?.env_vars?.forEach(env => {
if (!['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SEVERITY'].includes(env.name)) {
envVarValues.value[env.name] = getUIDefault(env.default)
}
})
document.body.style.overflow = 'hidden'
fetchYamlFromHub()
}
const closeSensor = () => { selectedSensor.value = null; envVarValues.value = {}; activeEnvVar.value = null; document.body.style.overflow = ''; }
const closeSensor = () => {
selectedSensor.value = null
envVarValues.value = {}
activeEnvVar.value = null
document.body.style.overflow = ''
}
const fetchYamlFromHub = async () => {
if (!selectedSensor.value) return;
const apiKey = getNodeApiKey()
const safeEnvValues = Object.fromEntries(Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : '']));
const payload = {
node_id: node.value.id,
hub_endpoint: config.hubEndpoint || window.location.origin,
hub_key: apiKey || '<YOUR_HW_NODE_KEY>',
sensors: [{ sensor_id: selectedSensor.value.id, env_values: safeEnvValues, manifest: selectedSensor.value }]
};
try {
const response = await fetch('/api/v1/compose/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!response.ok) throw new Error("Failed to compile YAML");
rawCompose.value = await response.text();
applyHighlighting();
} catch (e) {
console.error(e); rawCompose.value = "services:\n error:\n error_generating_yaml"; highlightedCompose.value = rawCompose.value;
}
if (!selectedSensor.value || !node.value?.id) return
const apiKey = node.value.apiKey
const safeEnvValues = Object.fromEntries(
Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : ''])
)
try {
rawCompose.value = await fleetStore.generateCompose({
node_id: node.value.id,
hub_endpoint: config.hubEndpoint || window.location.origin,
hub_key: apiKey || '<YOUR_HW_NODE_KEY>',
sensors: [{
sensor_id: selectedSensor.value.id,
env_values: safeEnvValues,
manifest: selectedSensor.value
}]
})
applyHighlighting()
} catch (e) {
rawCompose.value = 'services:\n error:\n error_generating_yaml'
highlightedCompose.value = rawCompose.value
}
}
const applyHighlighting = () => {
let htmlYaml = rawCompose.value;
if (activeEnvVar.value) {
const escapedName = activeEnvVar.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^.*\\b${escapedName}\\b.*$`, 'gm');
htmlYaml = htmlYaml.replace(regex, `<span class="bg-highlight-bg text-highlight-text ring-1 ring-highlight-ring px-1 rounded transition-colors duration-[var(--duration-fast)] active-highlight">$&</span>`);
let htmlYaml = rawCompose.value
if (activeEnvVar.value) {
const escapedName = activeEnvVar.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`^.*\\b${escapedName}\\b.*$`, 'gm')
htmlYaml = htmlYaml.replace(regex, `<span class="bg-highlight-bg text-highlight-text ring-1 ring-highlight-ring px-1 rounded transition-colors duration-[var(--duration-fast)] active-highlight">$&</span>`)
}
highlightedCompose.value = htmlYaml
nextTick(() => {
if (composePre.value) {
const highlightEl = composePre.value.querySelector('.active-highlight')
if (highlightEl) {
const scrollPos = highlightEl.offsetTop - (composePre.value.clientHeight / 2) + (highlightEl.clientHeight / 2)
composePre.value.scrollTo({ top: Math.max(0, scrollPos), behavior: 'smooth' })
}
}
highlightedCompose.value = htmlYaml;
nextTick(() => {
if (composePre.value) {
const highlightEl = composePre.value.querySelector('.active-highlight');
if (highlightEl) {
const scrollPos = highlightEl.offsetTop - (composePre.value.clientHeight / 2) + (highlightEl.clientHeight / 2);
composePre.value.scrollTo({ top: Math.max(0, scrollPos), behavior: 'smooth' });
}
}
});
})
}
</script>
@@ -424,7 +339,7 @@ const applyHighlighting = () => {
</button>
</div>
<div class="bg-bg-base border border-border-default rounded-[var(--radius-lg)] p-5 sm:p-6 mb-8 shrink-0 shadow-sm flex flex-col gap-6">
<div v-if="node" class="bg-bg-base border border-border-default rounded-[var(--radius-lg)] p-5 sm:p-6 mb-8 shrink-0 shadow-sm flex flex-col gap-6">
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div>
@@ -582,7 +497,7 @@ const applyHighlighting = () => {
<div class="shrink-0 mb-6">
<h2 class="text-lg font-semibold text-text-h mb-4">Sensor Catalog</h2>
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-if="isManifestLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="bg-bg-surface border border-border-default rounded-lg p-5 h-36 animate-pulse"></div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
+20 -30
View File
@@ -1,5 +1,6 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { useAppStore } from '../stores/app'
import { useConfig } from '../api/useConfig'
import BaseButton from '../components/ui/forms/BaseButton.vue'
import BaseInput from '../components/ui/forms/BaseInput.vue'
@@ -11,10 +12,10 @@ import BaseVerticalNav from '../components/ui/navigation/BaseVerticalNav.vue'
import BaseRadioGroup from '../components/ui/forms/BaseRadioGroup.vue'
import BaseNumberStepper from '../components/ui/forms/BaseNumberStepper.vue'
const appStore = useAppStore()
const { config, patchConfig } = useConfig()
const activeTab = ref('general')
// Left-hand Navigation Data
const settingTabs = [
{ id: 'general', label: 'Hub Configuration', iconSvg: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>' },
{ id: 'data', label: 'Data Retention', iconSvg: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>' },
@@ -115,6 +116,8 @@ const getSeverityStyle = (sev, isActive) => {
};
}
// --- SECURITY MODALS (ephemeral UI + store delegation) ---
const showPasswordModal = ref(false)
const pwdData = ref({ current: '', new: '', confirmNew: '' })
const pwdError = ref('')
@@ -136,22 +139,14 @@ const submitPasswordChange = async () => {
}
pwdLoading.value = true
pwdError.value = ''
try {
const res = await fetch('/api/v1/system/password', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: pwdData.value.current, new_password: pwdData.value.new })
})
if (res.ok) window.location.reload()
else if (res.status === 401) pwdError.value = "Incorrect current password."
else {
const err = await res.text()
pwdError.value = err || "Failed to update password."
}
} catch (e) {
pwdError.value = "Network error."
} finally {
pwdLoading.value = false
const result = await appStore.changePassword(pwdData.value.current, pwdData.value.new)
pwdLoading.value = false
if (result.success) {
window.location.reload()
} else {
pwdError.value = result.error
}
}
@@ -162,19 +157,14 @@ const submitFactoryReset = async () => {
}
resetLoading.value = true
resetError.value = ''
try {
const res = await fetch('/api/v1/system/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: resetPassword.value })
})
if (res.ok) window.location.reload()
else if (res.status === 401) resetError.value = "Incorrect password."
else resetError.value = "Factory reset failed."
} catch (e) {
resetError.value = "Network error."
} finally {
resetLoading.value = false
const result = await appStore.factoryReset(resetPassword.value)
resetLoading.value = false
if (result.success) {
window.location.reload()
} else {
resetError.value = result.error
}
}
</script>
+11 -16
View File
@@ -1,5 +1,6 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useAppStore } from '../stores/app'
import BaseInput from '../components/ui/forms/BaseInput.vue'
import BaseButton from '../components/ui/forms/BaseButton.vue'
import BaseCard from '../components/ui/layout/BaseCard.vue'
@@ -10,6 +11,8 @@ import ThemeToggle from '../components/ui/branding/ThemeToggle.vue'
import BaseLogo from '../components/ui/branding/BaseLogo.vue'
const emit = defineEmits(['setup-complete', 'toggle-theme'])
const appStore = useAppStore()
const password = ref('')
const confirmPassword = ref('')
const hubEndpoint = ref('')
@@ -40,22 +43,14 @@ const doSetup = async () => {
}
loading.value = true
error.value = ''
try {
const res = await fetch('/api/v1/setup', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
password: password.value,
hub_endpoint: hubEndpoint.value,
hub_key: hubKey.value
})
})
if (res.ok) emit('setup-complete')
else error.value = await res.text() || "Setup failed."
} catch (err) {
error.value = "Network error. Hub unreachable."
} finally {
loading.value = false
const result = await appStore.completeSetup(password.value, hubEndpoint.value, hubKey.value)
loading.value = false
if (result.success) {
emit('setup-complete')
} else {
error.value = result.error || "Setup failed."
}
}
</script>