mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
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:
+378
-361
@@ -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
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user