mirror of
https://github.com/andreicscs/HoneyWire
synced 2026-06-26 12:39:53 +00:00
refactored frontend Fleet store architecture ( fleet.js ), and updated components that used it to match new implementation.
This commit is contained in:
+1
-1
@@ -27,7 +27,7 @@ const { currentView, viewingArchive } = storeToRefs(appStore)
|
||||
|
||||
watch([viewingArchive, () => fleetStore.selectedNode, () => fleetStore.selectedSensor],
|
||||
([isArchived, node, sensor]) => {
|
||||
eventsStore.fetchEvents(isArchived, node, sensor)
|
||||
eventsStore.fetchEvents(isArchived, node?.id, sensor?.sensorId)
|
||||
})
|
||||
|
||||
watch(() => fleetStore.activeTimeframe, (newTimeframe) => {
|
||||
|
||||
@@ -126,8 +126,8 @@ onMounted(() => {
|
||||
|
||||
watch(
|
||||
() => [selectedNode.value, selectedSensor.value, appStore.viewingArchive],
|
||||
([nodeId, sensorId]) => {
|
||||
eventsStore.fetchSeverityProjection('alltime', nodeId, sensorId)
|
||||
([node, sensor]) => {
|
||||
eventsStore.fetchSeverityProjection('alltime', node?.id, sensor?.sensorId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -120,8 +120,8 @@ const updateData = () => {
|
||||
const fetchContextualProjection = () => {
|
||||
eventsStore.fetchThreatVelocityProjection(
|
||||
velocityTimeframe.value,
|
||||
selectedNode.value,
|
||||
selectedSensor.value,
|
||||
selectedNode.value?.id,
|
||||
selectedSensor.value?.sensorId,
|
||||
viewingArchive.value
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ const checkScroll = () => {
|
||||
showOfflineWarning.value = lastWarning.getBoundingClientRect().right > (container.getBoundingClientRect().right + 5)
|
||||
}
|
||||
|
||||
watch(() => selectedNode.value, (newVal) => {
|
||||
watch(() => selectedNode.value?.id, (newVal) => {
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(newVal ? `pill-${newVal}` : 'pill-all')
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
@@ -61,7 +61,7 @@ watch(() => selectedNode.value, (newVal) => {
|
||||
|
||||
watch(() => selectedSensor.value, (newVal) => {
|
||||
nextTick(() => {
|
||||
const elId = newVal && selectedNode.value ? `pill-${selectedNode.value}` : 'pill-all'
|
||||
const elId = newVal && selectedNode.value?.id ? `pill-${selectedNode.value?.id}` : 'pill-all'
|
||||
const el = document.getElementById(elId)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||
})
|
||||
@@ -92,13 +92,13 @@ onMounted(() => nextTick(checkScroll))
|
||||
<div v-for="n in activeNodes" :key="n.node_id" :id="'pill-' + n.node_id"
|
||||
class="shrink-0 relative flex items-center rounded-md border transition-all duration-normal shadow-sm group/pill"
|
||||
:class="[
|
||||
(selectedNode === n.node_id && !selectedSensor) ? 'bg-primary-selected text-primary-text border-primary-selected' :
|
||||
(selectedNode === n.node_id && selectedSensor) ? 'bg-highlight-bg border-highlight-border text-highlight-text ring-1 ring-highlight-ring' :
|
||||
(selectedNode?.id === n.node_id && !selectedSensor) ? 'bg-primary-selected text-primary-text border-primary-selected' :
|
||||
(selectedNode?.id === n.node_id && selectedSensor) ? 'bg-highlight-bg border-highlight-border text-highlight-text ring-1 ring-highlight-ring' :
|
||||
'bg-secondary-main border-secondary-border text-text-m hover:bg-secondary-hover hover:text-text-h',
|
||||
|
||||
['down', 'degraded'].includes(n.status) ? 'has-warnings' : '',
|
||||
|
||||
(selectedNode && selectedNode !== n.node_id) ? 'opacity-70' : ''
|
||||
(selectedNode && selectedNode?.id !== n.node_id) ? 'opacity-70' : ''
|
||||
]">
|
||||
|
||||
<div @click="fleetStore.selectTarget(n.node_id, null)" class="flex items-center gap-2 pl-2.5 py-1 cursor-pointer flex-1" :title="`${n.online}/${n.total} Sensors Online`">
|
||||
@@ -109,7 +109,7 @@ onMounted(() => nextTick(checkScroll))
|
||||
|
||||
<BaseMeatballMenu
|
||||
:id="`node-${n.node_id}`"
|
||||
:inverted="selectedNode === n.node_id && !selectedSensor"
|
||||
:inverted="selectedNode?.id === n.node_id && !selectedSensor"
|
||||
class="mx-2 opacity-50 group-hover/pill:opacity-100 transition-opacity"
|
||||
>
|
||||
<button @click="handleSilenceNode(n.node_id)"
|
||||
|
||||
@@ -18,7 +18,7 @@ const canScrollDown = ref(false)
|
||||
const worstWarningBelow = ref(null)
|
||||
|
||||
const handleSilence = (nodeId, sensorId) => fleetStore.toggleSilence(nodeId, sensorId)
|
||||
const handleForget = (nodeId, sensorId) => fleetStore.deleteSensor(nodeId, sensorId)
|
||||
const handleForget = (nodeId, sensorId) => fleetStore.removeSensor(nodeId, sensorId)
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!scrollArea.value) return
|
||||
@@ -160,9 +160,9 @@ const legendItems = [
|
||||
<div v-for="group in hydratedGroups" :key="group.node_id" :id="'group-' + group.node_id"
|
||||
class="transition-all duration-normal rounded-lg p-0.5 mb-0.5 border"
|
||||
:class="{
|
||||
'border-select-group-border bg-select-group-bg': selectedNode === group.node_id && !selectedSensor,
|
||||
'border-transparent': selectedNode !== group.node_id || selectedSensor,
|
||||
'opacity-50': (selectedNode || selectedSensor) && selectedNode !== group.node_id
|
||||
'border-select-group-border bg-select-group-bg': selectedNode?.id === group.node_id && !selectedSensor,
|
||||
'border-transparent': selectedNode?.id !== group.node_id || selectedSensor,
|
||||
'opacity-50': (selectedNode || selectedSensor) && selectedNode?.id !== group.node_id
|
||||
}">
|
||||
|
||||
<div class="px-1.5 mb-1 flex items-center gap-2 group/header"
|
||||
@@ -180,9 +180,9 @@ const legendItems = [
|
||||
<div v-for="sensor in group.sensors" :key="sensor.node_id + '-' + sensor.sensor_id" :id="'row-' + sensor.node_id + '-' + sensor.sensor_id"
|
||||
class="flex items-center w-full transition-all duration-normal px-1.5 h-7 rounded-md border"
|
||||
:class="{
|
||||
'opacity-50': selectedSensor && (selectedSensor !== sensor.sensor_id || selectedNode !== sensor.node_id),
|
||||
'bg-select-row-bg border-select-row-border shadow-sm': selectedSensor === sensor.sensor_id && selectedNode === sensor.node_id,
|
||||
'border-transparent': !selectedSensor || (selectedSensor !== sensor.sensor_id || selectedNode !== sensor.node_id),
|
||||
'opacity-50': selectedSensor && (selectedSensor?.sensorId !== sensor.sensor_id || selectedNode?.id !== sensor.node_id),
|
||||
'bg-select-row-bg border-select-row-border shadow-sm': selectedSensor?.sensorId === sensor.sensor_id && selectedNode?.id === sensor.node_id,
|
||||
'border-transparent': !selectedSensor || (selectedSensor?.sensorId !== sensor.sensor_id || selectedNode?.id !== sensor.node_id),
|
||||
'has-warnings': sensor.status !== 'up' && sensor.status !== 'nodata'
|
||||
}"
|
||||
:data-worst-status="sensor.status !== 'up' && sensor.status !== 'nodata' ? sensor.status : null"
|
||||
@@ -215,7 +215,7 @@ const legendItems = [
|
||||
|
||||
<button @click="fleetStore.selectTarget(sensor.node_id, sensor.sensor_id)"
|
||||
class="font-mono text-left transition-colors cursor-pointer rounded flex items-center gap-1.5 max-w-[calc(100%-28px)] text-sm"
|
||||
:class="selectedSensor === sensor.sensor_id && selectedNode === sensor.node_id ? 'text-text-h font-bold' : 'text-text-m font-medium hover:text-text-h'"
|
||||
:class="selectedSensor?.sensorId === sensor.sensor_id && selectedNode?.id === sensor.node_id ? 'text-text-h font-bold' : 'text-text-m font-medium hover:text-text-h'"
|
||||
:title="`Node: ${group.node_alias || group.node_id}`">
|
||||
<span class="truncate">{{ formatSensorId(sensor.sensor_id) }}</span>
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Fleet Store Architecture Guide
|
||||
|
||||
This document describes the architectural design, state segregation, and mutation strategies used in the `fleet.js` Pinia store.
|
||||
|
||||
The system has been refactored away from deep-object mutation and nested arrays toward a flat, normalized, and strictly gatekept state model. This ensures predictable Vue reactivity, race-condition immunity during WebSocket bursts, and O(1) relational lookups.
|
||||
|
||||
---
|
||||
|
||||
# Architecture Overview
|
||||
|
||||
## The 6-Phase State Model
|
||||
|
||||
```text
|
||||
Incoming Payloads (HTTP / WS)
|
||||
↓
|
||||
Normalization & Composite ID Mapping
|
||||
↓
|
||||
State Gatekeepers (Snapshot vs Entity Patchers)
|
||||
↓
|
||||
Flat Authoritative Dictionaries (nodesById, sensorsById)
|
||||
↓
|
||||
Dynamic Read Models (Computed Projections)
|
||||
↓
|
||||
UI Components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 1. State Segregation
|
||||
|
||||
The store strictly divides its reactive variables into three distinct categories. **Never mix these boundaries.**
|
||||
|
||||
### A. Authoritative Backend Snapshot State
|
||||
The absolute source of truth. Contains flattened dictionaries keyed by strictly formatted IDs.
|
||||
```javascript
|
||||
const nodesById = ref({})
|
||||
const sensorsById = ref({}) // Keyed by composite ID: `${nodeId}:${rawSensorId}`
|
||||
```
|
||||
|
||||
### B. Frontend Operational UI State
|
||||
Ephemeral state owned purely by the user's current session. Never sent to the backend as-is.
|
||||
```javascript
|
||||
const pendingNodeActions = ref(new Map())
|
||||
const selectedNodeId = ref(null)
|
||||
const selectedSensorId = ref(null) // Strictly holds composite ID
|
||||
```
|
||||
|
||||
### C. Transport Control
|
||||
Prevents race conditions (e.g., overlapping fetches or WebSocket overlaps) using `AbortController`.
|
||||
```javascript
|
||||
const abortControllers = new Map()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 2. Data Normalization & Invariants
|
||||
|
||||
Raw API payloads are **never** injected directly into state. They pass through pure normalization functions.
|
||||
|
||||
## The Composite Sensor Key
|
||||
Sensors do not have globally unique IDs from the backend (a `tcp-tarpit` on Node A has the same raw ID as a `tcp-tarpit` on Node B). To prevent state collisions, the store enforces a strict composite key invariant:
|
||||
|
||||
`Composite ID = {nodeId}:{rawSensorId}`
|
||||
|
||||
All internal dictionaries use the Composite ID. Public actions exposed to UI components require both the `nodeId` and `rawSensorId` to construct this key safely.
|
||||
|
||||
---
|
||||
|
||||
# 3. The Two Mutation Gatekeepers
|
||||
|
||||
Direct mutation of `nodesById` or `sensorsById` is strictly forbidden outside of these internal helper functions. This enforces the immutable *clone-update-replace* pattern required by Vue for reliable UI updates, and prevents WebSocket patches from bypassing validation or identity invariants.
|
||||
|
||||
### Gatekeeper 1: `commitStructuralSnapshot`
|
||||
Used exclusively for HTTP responses. Atomically replaces entire object branches.
|
||||
```javascript
|
||||
const commitStructuralSnapshot = (nextNodes, nextSensors) => {
|
||||
nodesById.value = nextNodes
|
||||
sensorsById.value = nextSensors
|
||||
}
|
||||
```
|
||||
|
||||
### Gatekeeper 2: `applyWsEphemeralPatch`
|
||||
Used exclusively by the WebSocket handler. Patches isolated fields (like `lastHeartbeat`) on existing entities without triggering full graph re-renders.
|
||||
```javascript
|
||||
const applyWsEphemeralPatch = (nodeId, sensorId, nodePatch, sensorPatch) => {
|
||||
// Shallow clone and patch
|
||||
if (nodeId && nodesById.value[nodeId] && nodePatch) {
|
||||
nodesById.value[nodeId] = { ...nodesById.value[nodeId], ...nodePatch }
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Gatekeeper 3: `applyOptimisticPatch`
|
||||
Used by UI-driven API mutations. Captures the prior state and returns it, allowing immediate UI updates and providing an automatic rollback path if the HTTP request fails.
|
||||
```javascript
|
||||
const applyOptimisticPatch = (nodeId, patch) => {
|
||||
const existing = nodesById.value[nodeId]
|
||||
if (!existing) return null
|
||||
const previousState = { ...existing }
|
||||
nodesById.value[nodeId] = { ...existing, ...patch }
|
||||
return previousState
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. Dynamic Read Models (Selectors)
|
||||
|
||||
Because the authoritative state is flat, the store dynamically reconstructs the relational graph (Nodes -> Sensors) via computed properties.
|
||||
|
||||
## O(N) Compute -> O(1) Access
|
||||
The `sensorsByNodeId` computed property iterates over sensors once when the dependency graph changes, building a map.
|
||||
|
||||
```javascript
|
||||
const sensorsByNodeId = computed(() => {
|
||||
const map = {}
|
||||
for (const s of Object.values(sensorsById.value)) {
|
||||
(map[s.nodeId] ||= []).push(s)
|
||||
}
|
||||
return map
|
||||
})
|
||||
```
|
||||
|
||||
## Safe Data Hydration
|
||||
When components request a Node, the store dynamically attaches the sensors.
|
||||
```javascript
|
||||
const getNode = (id) => {
|
||||
const node = nodesById.value[id]
|
||||
if (!node) return null
|
||||
return {
|
||||
...node,
|
||||
installedSensors: sensorsByNodeId.value[node.id] || []
|
||||
}
|
||||
}
|
||||
```
|
||||
*Rule:* Views must access nodes through `getNode()` or `selectedNode` to guarantee sensors are present.
|
||||
|
||||
---
|
||||
|
||||
# 5. WebSocket Update Rules
|
||||
|
||||
The WebSocket handler (`handleWsUpdate`) enforces strict boundary logic to prevent state drift between the backend and the frontend.
|
||||
|
||||
## Rule 1: Ephemeral Data = Immediate Local Patch
|
||||
High-frequency telemetry (Heartbeats, Event Counters) are patched locally using `applyWsEphemeralPatch`. This prevents the UI from resetting or flashing during burst events.
|
||||
|
||||
## Rule 2: Structural Data = Authoritative Snapshot Fetch
|
||||
Changes to the fleet topology (New Node, Deleted Sensor, Config Sync) **never** mutate the local dictionaries directly. Instead, they trigger a targeted HTTP fetch via `fetchNodeDetails(nodeId)`.
|
||||
|
||||
```javascript
|
||||
// Ephemeral telemetry (Direct Patch)
|
||||
if (type === 'SENSOR_HEARTBEAT') {
|
||||
applyWsEphemeralPatch(...)
|
||||
return
|
||||
}
|
||||
|
||||
// Structural topology change (Refetch)
|
||||
if (type === 'NEW_SENSOR' || type === 'DELETE_SENSOR') {
|
||||
fetchNodeDetails(payload.node_id)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. O(K) Garbage Collection (Sub-graph Overwrites)
|
||||
|
||||
When `fetchNodeDetails(nodeId)` runs to update a single node's snapshot, it must safely remove old sensors that no longer exist on the backend, without touching sensors belonging to other nodes.
|
||||
|
||||
Instead of an O(N) scan across all fleet sensors, it uses the O(1) `sensorsByNodeId` map to selectively prune the dictionary:
|
||||
|
||||
```javascript
|
||||
const nextSensors = { ...sensorsById.value }
|
||||
|
||||
// O(K) Garbage Collection using the computed index
|
||||
const oldSensors = sensorsByNodeId.value[node.id] || []
|
||||
for (const s of oldSensors) delete nextSensors[s.id]
|
||||
|
||||
// Insert new incoming sensors
|
||||
// ...
|
||||
commitStructuralSnapshot(nextNodes, nextSensors)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Important Architectural Rules
|
||||
|
||||
1. **Components never use composite IDs:** UI actions invoke actions using `(nodeId, rawSensorId)`. The store creates the composite key internally.
|
||||
2. **No Deep Reactivity Mutations:** Arrays and Objects are never modified using `.push()` or `.splice()` inside the state tree. They are cloned, patched, and replaced via the Gatekeepers.
|
||||
3. **UI Selection is an Object:** `selectedNode` and `selectedSensor` export complete, hydrated Objects, not just String IDs.
|
||||
+19
-18
@@ -27,14 +27,16 @@ export const useEventsStore = defineStore('events', () => {
|
||||
const isFetchingThreatVelocityProjection = ref(false)
|
||||
const lastVelocityInvalidation = ref(null)
|
||||
|
||||
// Store injections must live at the top-level setup scope to maintain pure reactivity tracking
|
||||
const fleetStore = useFleetStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// --- GETTERS ---
|
||||
/**
|
||||
* Return events filtered by the fleet store's selectedNode and selectedSensor
|
||||
* Composite Key filtering: if both are selected, filter by both.
|
||||
*/
|
||||
const filteredEvents = computed(() => {
|
||||
const fleetStore = useFleetStore()
|
||||
const appStore = useAppStore()
|
||||
const { selectedNode, selectedSensor } = fleetStore
|
||||
const isArchiveView = appStore.viewingArchive
|
||||
|
||||
@@ -42,9 +44,10 @@ export const useEventsStore = defineStore('events', () => {
|
||||
// (Assuming the backend returns a mix, or 'is_archived' property exists)
|
||||
// If your backend only returns one type based on the fetch URL, skip this local filter
|
||||
// and let the URL param handle it. But doing it locally ensures safety.
|
||||
let currentEvents = events.value.filter(e =>
|
||||
isArchiveView ? e.is_archived === true || e.is_archived === 1 : e.is_archived === false || e.is_archived === 0 || e.is_archived === undefined
|
||||
)
|
||||
let currentEvents = events.value.filter(e => {
|
||||
const isArchived = e.is_archived === true || e.is_archived === 1
|
||||
return isArchiveView ? isArchived : !isArchived
|
||||
})
|
||||
|
||||
// 2. No node/sensor filter: return events
|
||||
if (!selectedNode && !selectedSensor) {
|
||||
@@ -53,13 +56,13 @@ export const useEventsStore = defineStore('events', () => {
|
||||
|
||||
// 3. Node selected but no sensor: filter by node only
|
||||
if (selectedNode && !selectedSensor) {
|
||||
return currentEvents.filter(e => e.node_id === selectedNode)
|
||||
return currentEvents.filter(e => e.node_id === selectedNode?.id)
|
||||
}
|
||||
|
||||
// 4. Both selected
|
||||
if (selectedNode && selectedSensor) {
|
||||
return currentEvents.filter(
|
||||
e => e.node_id === selectedNode && e.sensor_id === selectedSensor
|
||||
e => e.node_id === selectedNode?.id && e.sensor_id === selectedSensor?.sensorId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,7 +122,6 @@ export const useEventsStore = defineStore('events', () => {
|
||||
severityAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
const appStore = useAppStore()
|
||||
const viewingArchive = appStore.viewingArchive
|
||||
const params = new URLSearchParams({ timeframe });
|
||||
if (nodeId) params.append('node', nodeId);
|
||||
@@ -248,7 +250,7 @@ export const useEventsStore = defineStore('events', () => {
|
||||
|
||||
// Refetch to update the list
|
||||
const fleetStore = useFleetStore()
|
||||
await fetchEvents(false, fleetStore.selectedNode, fleetStore.selectedSensor)
|
||||
await fetchEvents(false, fleetStore.selectedNode?.id, fleetStore.selectedSensor?.sensorId)
|
||||
} catch (err) {
|
||||
console.error('Failed to archive all events:', err)
|
||||
alert('Failed to archive events. Please try again.')
|
||||
@@ -270,8 +272,8 @@ export const useEventsStore = defineStore('events', () => {
|
||||
* @param {object} payload - Event payload from WebSocket
|
||||
*/
|
||||
const handleWsEvent = (payload) => {
|
||||
const fleetStore = useFleetStore()
|
||||
const { selectedNode, selectedSensor } = fleetStore
|
||||
const selectedNode = fleetStore.selectedNode
|
||||
const selectedSensor = fleetStore.selectedSensor
|
||||
|
||||
// Always increment unread count (event came from backend regardless)
|
||||
unreadCount.value++
|
||||
@@ -282,28 +284,27 @@ export const useEventsStore = defineStore('events', () => {
|
||||
|
||||
// 2. If node is selected but no sensor, match node only
|
||||
const nodeOnlyMatch =
|
||||
selectedNode && !selectedSensor && payload.node_id === selectedNode
|
||||
selectedNode && !selectedSensor && payload.node_id === selectedNode?.id
|
||||
|
||||
// 3. COMPOSITE KEY: If specific sensor is selected, match BOTH node AND sensor
|
||||
const sensorMatch =
|
||||
selectedSensor &&
|
||||
selectedNode &&
|
||||
payload.node_id === selectedNode &&
|
||||
payload.sensor_id === selectedSensor
|
||||
payload.node_id === selectedNode?.id &&
|
||||
payload.sensor_id === selectedSensor?.sensorId
|
||||
|
||||
// Add event to the front if it matches the current filter
|
||||
if (noFilter || nodeOnlyMatch || sensorMatch) {
|
||||
events.value.unshift(payload)
|
||||
}
|
||||
|
||||
const appStore = useAppStore();
|
||||
const affectsCurrentView =
|
||||
(!appStore.viewingArchive) &&
|
||||
(!selectedNode || selectedNode === payload.node_id) &&
|
||||
(!selectedSensor || selectedSensor === payload.sensor_id);
|
||||
(!selectedNode || selectedNode?.id === payload.node_id) &&
|
||||
(!selectedSensor || selectedSensor?.sensorId === payload.sensor_id);
|
||||
|
||||
if (affectsCurrentView) {
|
||||
fetchSeverityProjection('alltime', selectedNode, selectedSensor);
|
||||
fetchSeverityProjection('alltime', selectedNode?.id, selectedSensor?.sensorId);
|
||||
invalidateThreatVelocityProjection();
|
||||
}
|
||||
}
|
||||
|
||||
+409
-474
@@ -3,25 +3,136 @@ import { ref, computed } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export const useFleetStore = defineStore('fleet', () => {
|
||||
// --- STATE ---
|
||||
const nodes = ref([])
|
||||
const uptimeData = ref([])
|
||||
const selectedNode = ref(null)
|
||||
const selectedSensor = ref(null)
|
||||
// --- 1. AUTHORITATIVE BACKEND SNAPSHOT STATE ---
|
||||
const nodesById = ref({})
|
||||
const sensorsById = ref({})
|
||||
|
||||
// --- 2. FRONTEND OPERATIONAL UI STATE ---
|
||||
const pendingNodeActions = ref(new Map())
|
||||
const pendingSensorActions = ref(new Map())
|
||||
const selectedNodeId = ref(null)
|
||||
const selectedSensorId = ref(null)
|
||||
const activeTimeframe = ref('24H')
|
||||
|
||||
// Existing state for uptime
|
||||
const uptimeData = ref([])
|
||||
|
||||
// --- 3. TRANSPORT CONTROL (Race Condition Protection) ---
|
||||
const abortControllers = new Map()
|
||||
|
||||
// --- UI METADATA HELPERS ---
|
||||
const markNodeAction = (nodeId, action) => {
|
||||
const current = pendingNodeActions.value.get(nodeId) || new Set()
|
||||
current.add(action)
|
||||
pendingNodeActions.value = new Map(pendingNodeActions.value).set(nodeId, current)
|
||||
}
|
||||
|
||||
const clearNodeAction = (nodeId, action) => {
|
||||
const current = pendingNodeActions.value.get(nodeId)
|
||||
if (!current) return
|
||||
current.delete(action)
|
||||
const next = new Map(pendingNodeActions.value)
|
||||
if (current.size === 0) next.delete(nodeId)
|
||||
else next.set(nodeId, current)
|
||||
pendingNodeActions.value = next
|
||||
}
|
||||
|
||||
const isNodeActionPending = (nodeId, action) => {
|
||||
return pendingNodeActions.value.get(nodeId)?.has(action) || false
|
||||
}
|
||||
|
||||
// --- THE 3 MUTATION GATEKEEPERS ---
|
||||
const commitStructuralSnapshot = (nextNodes, nextSensors) => {
|
||||
nodesById.value = nextNodes
|
||||
sensorsById.value = nextSensors
|
||||
}
|
||||
|
||||
const patchNode = (nodeId, patch) => {
|
||||
const existing = nodesById.value[nodeId]
|
||||
if (!existing) return null
|
||||
const previousState = { ...existing }
|
||||
nodesById.value[nodeId] = { ...existing, ...patch }
|
||||
return previousState
|
||||
}
|
||||
|
||||
const patchSensor = (compositeSensorId, patch) => {
|
||||
const existing = sensorsById.value[compositeSensorId]
|
||||
if (!existing) return null
|
||||
const previousState = { ...existing }
|
||||
sensorsById.value[compositeSensorId] = { ...existing, ...patch }
|
||||
return previousState
|
||||
}
|
||||
|
||||
// --- DYNAMIC READ MODELS (Selectors) ---
|
||||
const sensorsByNodeId = computed(() => {
|
||||
const map = {}
|
||||
for (const s of Object.values(sensorsById.value)) {
|
||||
(map[s.nodeId] ||= []).push(s)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const nodes = computed(() => {
|
||||
return Object.values(nodesById.value).map(node => ({
|
||||
...node,
|
||||
installedSensors: sensorsByNodeId.value[node.id] || []
|
||||
}))
|
||||
})
|
||||
|
||||
const getNode = (id) => {
|
||||
const node = nodesById.value[id]
|
||||
if (!node) return null
|
||||
return {
|
||||
...node,
|
||||
installedSensors: sensorsByNodeId.value[node.id] || []
|
||||
}
|
||||
}
|
||||
|
||||
const getSensor = (nodeId, rawSensorId) => {
|
||||
if (!nodeId || !rawSensorId) return null
|
||||
return sensorsById.value[`${nodeId}:${rawSensorId}`] || null
|
||||
}
|
||||
|
||||
// Computed properties for selected targets - Exported as Objects (Option A)
|
||||
const selectedNode = computed(() => getNode(selectedNodeId.value))
|
||||
const selectedSensor = computed(() => {
|
||||
if (!selectedNodeId.value || !selectedSensorId.value) return null;
|
||||
return sensorsById.value[selectedSensorId.value] || null;
|
||||
})
|
||||
|
||||
// --- NORMALIZATION ---
|
||||
|
||||
const normalizeSensor = (raw) => {
|
||||
if (!raw) return null
|
||||
const normalizeNodeData = (raw) => {
|
||||
const id = raw.node_id ?? raw.id
|
||||
if (!id) 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,
|
||||
id,
|
||||
alias: raw.alias || raw.name || 'Unnamed Node',
|
||||
status: raw.status || 'unknown',
|
||||
hasPendingConfig: raw.hasPendingConfig ?? raw.pending_config ?? raw.has_pending_config ?? false,
|
||||
lastHeartbeat: raw.lastHeartbeat || raw.last_heartbeat || null,
|
||||
publicIp: raw.publicIp || raw.public_ip || null,
|
||||
privateIp: raw.privateIp || raw.private_ip || null,
|
||||
tags: raw.tags || [],
|
||||
apiKey: raw.apiKey || raw.api_key || null,
|
||||
activeRevision: raw.activeRevision || raw.active_revision || '',
|
||||
desiredRevision: raw.desiredRevision || raw.desired_revision || '',
|
||||
lastEvent: raw.lastEvent || raw.last_event || 'Never',
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSensorData = (raw, nodeId) => {
|
||||
const rawId = raw.sensor_id ?? raw.id
|
||||
if (!rawId) return null
|
||||
const id = `${nodeId}:${rawId}`
|
||||
return {
|
||||
id,
|
||||
sensorId: rawId,
|
||||
nodeId,
|
||||
name: raw.name || raw.sensor_id || rawId,
|
||||
display: raw.display || raw.custom_name || raw.name || raw.sensor_id || rawId,
|
||||
status: raw.status || 'down',
|
||||
events24h: raw.events24h ?? raw.events_24h ?? 0,
|
||||
isSilenced: raw.isSilenced ?? raw.is_silenced ?? false,
|
||||
events24h: raw.events24h ?? raw.events_24h ?? 0,
|
||||
osi: raw.osi_layer || raw.osi || 'Sensor',
|
||||
icon: raw.icon || raw.icon_svg || 'M12 12h0',
|
||||
envVars: raw.envVars || raw.env_vars || {},
|
||||
@@ -30,147 +141,150 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeNode = (raw) => {
|
||||
if (!raw) return null
|
||||
return {
|
||||
...raw,
|
||||
id: raw.id || raw.node_id || raw.nodeId,
|
||||
alias: raw.alias || raw.name || '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 || []
|
||||
if (!existing.installedSensors) existing.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)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy properties safely to preserve Vue 3 Proxy array identity.
|
||||
// This prevents breaking the reactive reference that components are watching.
|
||||
for (const key of Object.keys(incoming)) {
|
||||
if (key !== 'installedSensors') {
|
||||
existing[key] = incoming[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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(() => {
|
||||
// Use the overall_uptime from the API response if available
|
||||
if (uptimeData.value && uptimeData.value.summary && typeof uptimeData.value.summary.overall_uptime === 'number') {
|
||||
return uptimeData.value.summary.overall_uptime.toFixed(2) + '%'
|
||||
}
|
||||
return '0.0%'
|
||||
})
|
||||
|
||||
// --- ACTIONS: FETCH ---
|
||||
|
||||
// --- ACTIONS: STRUCTURAL INGESTION ---
|
||||
const fetchFleet = async () => {
|
||||
try {
|
||||
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)
|
||||
const incoming = Array.isArray(raw) ? raw : raw.nodes || []
|
||||
|
||||
const nextNodes = {}
|
||||
const nextSensors = {}
|
||||
|
||||
for (const rawNode of incoming) {
|
||||
const node = normalizeNodeData(rawNode)
|
||||
if (!node) continue
|
||||
nextNodes[node.id] = node
|
||||
|
||||
const sensors = rawNode.installedSensors || rawNode.installed_sensors || []
|
||||
|
||||
for (const rs of sensors) {
|
||||
const sensor = normalizeSensorData(rs, node.id)
|
||||
if (sensor) {
|
||||
nextSensors[sensor.id] = sensor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commitStructuralSnapshot(nextNodes, nextSensors)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch fleet data', e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNodeDetails = async (nodeId) => {
|
||||
try {
|
||||
const res = await api.get(`/api/v1/nodes/${encodeURIComponent(nodeId)}`)
|
||||
const raw = await res.json()
|
||||
const normalized = normalizeNode(raw)
|
||||
if (abortControllers.has(nodeId)) abortControllers.get(nodeId).abort()
|
||||
const controller = new AbortController()
|
||||
abortControllers.set(nodeId, controller)
|
||||
|
||||
const existing = getNode(nodeId)
|
||||
if (existing) {
|
||||
mergeNode(existing, normalized)
|
||||
return existing
|
||||
} else {
|
||||
nodes.value.push(normalized)
|
||||
return normalized
|
||||
try {
|
||||
const res = await api.get(`/api/v1/nodes/${encodeURIComponent(nodeId)}`, { signal: controller.signal })
|
||||
const rawNode = await res.json()
|
||||
const node = normalizeNodeData(rawNode)
|
||||
if (!node) return
|
||||
|
||||
const nextNodes = { ...nodesById.value, [node.id]: node }
|
||||
const nextSensors = { ...sensorsById.value }
|
||||
|
||||
// O(K) Garbage Collection using the computed index
|
||||
const oldSensors = sensorsByNodeId.value[node.id] || []
|
||||
for (const s of oldSensors) delete nextSensors[s.id]
|
||||
|
||||
const rawSensors = rawNode.installedSensors || rawNode.installed_sensors || []
|
||||
for (const rs of rawSensors) {
|
||||
const sensor = normalizeSensorData(rs, node.id)
|
||||
if (sensor) nextSensors[sensor.id] = sensor
|
||||
}
|
||||
|
||||
commitStructuralSnapshot(nextNodes, nextSensors)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch node details:', e)
|
||||
return null
|
||||
if (e.name !== 'AbortError') console.error('Failed to fetch node subgraph:', e)
|
||||
} finally {
|
||||
if (abortControllers.get(nodeId) === controller) abortControllers.delete(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HIGH-FREQUENCY RUNTIME PATCHES ---
|
||||
const handleWsUpdate = (type, payload) => {
|
||||
if (type === 'SENSOR_HEARTBEAT') {
|
||||
const compositeSensorId = `${payload.node_id}:${payload.sensor_id}`
|
||||
patchNode(payload.node_id, { lastHeartbeat: payload.timestamp, status: 'up' })
|
||||
patchSensor(compositeSensorId, { lastHeartbeat: payload.timestamp, status: 'up' })
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'NEW_EVENT') {
|
||||
const compositeSensorId = `${payload.node_id}:${payload.sensor_id}`
|
||||
patchNode(payload.node_id, { lastEvent: 'Just now' })
|
||||
|
||||
const events = payload.events24h ?? payload.events_24h
|
||||
if (events !== undefined) {
|
||||
patchSensor(compositeSensorId, { events24h: events })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'SILENCE_SENSOR') {
|
||||
const compositeSensorId = `${payload.node_id}:${payload.sensor_id}`
|
||||
const targetState = payload.is_silenced ?? payload.isSilenced
|
||||
if (targetState !== undefined) {
|
||||
patchSensor(compositeSensorId, { isSilenced: targetState })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Structural boundaries trigger refetches, NEVER local mapping mutations
|
||||
if (type === 'NEW_SENSOR' || type === 'UPDATE_SENSOR' || type === 'DELETE_SENSOR' || type === 'NODE_SYNCED' || type === 'UPDATE_NODE') {
|
||||
fetchNodeDetails(payload.node_id || payload.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'NEW_NODE' || type === 'DELETE_NODE') {
|
||||
fetchFleet()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNode = async (nodeId) => {
|
||||
if (!confirm(`Delete Node "${nodeId}" and ALL of its underlying sensors?`)) return
|
||||
markNodeAction(nodeId, 'deleting')
|
||||
|
||||
try {
|
||||
await api.delete(`/api/v1/nodes/${nodeId}`)
|
||||
if (selectedNodeId.value === nodeId) {
|
||||
selectedNodeId.value = null
|
||||
selectedSensorId.value = null
|
||||
}
|
||||
await fetchFleet()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete node:', err)
|
||||
} finally {
|
||||
clearNodeAction(nodeId, 'deleting')
|
||||
}
|
||||
}
|
||||
|
||||
// --- ACTIONS: SELECTION ---
|
||||
const clearSelection = () => {
|
||||
selectedNodeId.value = null
|
||||
selectedSensorId.value = null
|
||||
}
|
||||
|
||||
const selectTarget = (nodeId, rawSensorId = null) => {
|
||||
const compositeId = rawSensorId ? `${nodeId}:${rawSensorId}` : null
|
||||
|
||||
const sameNode = selectedNodeId.value === nodeId
|
||||
const sameSensor = selectedSensorId.value === compositeId
|
||||
|
||||
if (sameNode && sameSensor) {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
selectedNodeId.value = nodeId
|
||||
selectedSensorId.value = compositeId
|
||||
}
|
||||
|
||||
// --- ACTIONS: UPTIME & MANIFESTS ---
|
||||
const fetchUptime = async (timeframe) => {
|
||||
const target = timeframe || activeTimeframe.value
|
||||
try {
|
||||
@@ -181,6 +295,13 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const overallUptime = computed(() => {
|
||||
if (uptimeData.value && uptimeData.value.summary && typeof uptimeData.value.summary.overall_uptime === 'number') {
|
||||
return uptimeData.value.summary.overall_uptime.toFixed(2) + '%'
|
||||
}
|
||||
return '0.0%'
|
||||
})
|
||||
|
||||
const fetchManifests = async () => {
|
||||
try {
|
||||
const res = await api.get('/api/v1/manifests')
|
||||
@@ -190,243 +311,16 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// --- ACTIONS: SELECTION ---
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedNode.value = null
|
||||
selectedSensor.value = null
|
||||
}
|
||||
|
||||
const selectTarget = (nodeId, sensorId = null) => {
|
||||
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 || data.id,
|
||||
alias,
|
||||
tags,
|
||||
status: 'pending',
|
||||
apiKey: data.api_key || data.apiKey || data.key,
|
||||
installedSensors: [],
|
||||
})
|
||||
nodes.value.push(partialNode)
|
||||
|
||||
// Background refresh for full details
|
||||
fetchNodeDetails(partialNode.id)
|
||||
|
||||
return {
|
||||
nodeId: partialNode.id,
|
||||
apiKey: partialNode.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 {
|
||||
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 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 {
|
||||
await api.patch(`/api/v1/nodes/${nodeId}`, payload)
|
||||
} catch (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 updateSensor = async (nodeId, sensorId, { customName, configValues }) => {
|
||||
const node = getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const sensor = getSensor(nodeId, sensorId)
|
||||
if (!sensor) return
|
||||
|
||||
const previous = { ...sensor }
|
||||
const previousPending = node.hasPendingConfig
|
||||
|
||||
sensor.display = customName || sensorId
|
||||
sensor.envVars = configValues
|
||||
node.hasPendingConfig = true
|
||||
|
||||
try {
|
||||
await api.put(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors/${encodeURIComponent(sensorId)}`, {
|
||||
custom_name: customName || sensorId,
|
||||
config_values: configValues,
|
||||
})
|
||||
fetchNodeDetails(nodeId)
|
||||
} catch (err) {
|
||||
mergeSensor(sensor, previous)
|
||||
node.hasPendingConfig = previousPending
|
||||
console.error('Failed to update 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 {
|
||||
await api.patch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}/silence`, {
|
||||
is_silenced: targetState,
|
||||
})
|
||||
} catch (err) {
|
||||
sensor.isSilenced = previous
|
||||
console.error('Failed to toggle sensor silence:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const silenceNode = async (nodeId) => {
|
||||
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 =>
|
||||
api.patch(`/api/v1/nodes/${nodeId}/sensors/${s.id}/silence`, {
|
||||
is_silenced: targetState,
|
||||
})
|
||||
))
|
||||
} catch (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}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch compose YAML: ${res.statusText}`)
|
||||
}
|
||||
return await res.text()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch compose:', err)
|
||||
@@ -446,132 +340,173 @@ export const useFleetStore = defineStore('fleet', () => {
|
||||
|
||||
const syncNode = async (nodeId) => {
|
||||
const node = getNode(nodeId)
|
||||
if (!node?.apiKey) {
|
||||
if (!node) {
|
||||
throw new Error(`Unable to sync: node ${nodeId} not found`)
|
||||
}
|
||||
if (!node.apiKey) {
|
||||
throw new Error('Unable to sync: missing node API key')
|
||||
}
|
||||
|
||||
const composeYaml = await fetchCompose(node.apiKey)
|
||||
node.hasPendingConfig = false
|
||||
patchNode(nodeId, { hasPendingConfig: false })
|
||||
fetchNodeDetails(nodeId)
|
||||
|
||||
return composeYaml
|
||||
}
|
||||
|
||||
// --- WEBSOCKET HANDLER ---
|
||||
|
||||
const handleWsUpdate = (type, payload) => {
|
||||
if (type === 'SENSOR_HEARTBEAT') {
|
||||
const node = getNode(payload.node_id)
|
||||
if (node) {
|
||||
node.lastHeartbeat = payload.timestamp
|
||||
node.status = 'up'
|
||||
const sensor = getSensor(payload.node_id, payload.sensor_id)
|
||||
if (sensor) {
|
||||
sensor.lastHeartbeat = payload.timestamp
|
||||
sensor.status = 'up'
|
||||
}
|
||||
// --- ACTIONS: NODE MUTATIONS ---
|
||||
const createNode = async (alias, tags = []) => {
|
||||
try {
|
||||
const res = await api.post('/api/v1/nodes', { alias, tags })
|
||||
const data = await res.json()
|
||||
|
||||
const nodeId = data.node_id || data.nodeId || data.id
|
||||
const apiKey = data.api_key || data.apiKey || data.key
|
||||
|
||||
await fetchFleet()
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
apiKey,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
// Guarantee UI is 100% synced with the newly deployed sensors
|
||||
fetchNodeDetails(payload.node_id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'UPDATE_NODE') {
|
||||
if (payload.trigger_refresh) {
|
||||
fetchNodeDetails(payload.id)
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = normalizeNode(payload)
|
||||
const targetNode = getNode(normalized.id)
|
||||
if (targetNode) mergeNode(targetNode, normalized)
|
||||
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 nodeId = payload.id || payload.node_id || payload.nodeId
|
||||
const exists = getNode(nodeId)
|
||||
if (!exists) {
|
||||
nodes.value.push(normalizeNode(payload))
|
||||
}
|
||||
if (nodeId) fetchNodeDetails(nodeId)
|
||||
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
|
||||
} catch (err) {
|
||||
console.error('Failed to create node:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const updateNode = async (nodeId, payload) => {
|
||||
try {
|
||||
// Optimistic update using rollback cache
|
||||
const previousState = patchNode(nodeId, {
|
||||
alias: payload.alias !== undefined ? payload.alias : undefined,
|
||||
tags: payload.tags !== undefined ? payload.tags : undefined,
|
||||
publicIp: payload.publicIp !== undefined ? payload.publicIp : undefined,
|
||||
privateIp: payload.privateIp !== undefined ? payload.privateIp : undefined,
|
||||
})
|
||||
|
||||
await api.patch(`/api/v1/nodes/${nodeId}`, payload)
|
||||
} catch (err) {
|
||||
if (previousState) patchNode(nodeId, previousState)
|
||||
console.error('Failed to update node:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// --- ACTIONS: SENSOR MUTATIONS ---
|
||||
const addSensor = async (nodeId, { sensorId: rawSensorId, customName, configValues }) => {
|
||||
try {
|
||||
await api.post(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors`, {
|
||||
sensor_id: rawSensorId,
|
||||
custom_name: customName || rawSensorId,
|
||||
config_values: configValues,
|
||||
})
|
||||
fetchNodeDetails(nodeId)
|
||||
} catch (err) {
|
||||
console.error('Failed to add sensor:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const updateSensor = async (nodeId, rawSensorId, { customName, configValues }) => {
|
||||
const sensor = getSensor(nodeId, rawSensorId)
|
||||
if (!sensor) return
|
||||
try {
|
||||
await api.put(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors/${encodeURIComponent(rawSensorId)}`, {
|
||||
custom_name: customName || rawSensorId,
|
||||
config_values: configValues,
|
||||
})
|
||||
fetchNodeDetails(nodeId)
|
||||
} catch (err) {
|
||||
console.error('Failed to update sensor:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const removeSensor = async (nodeId, rawSensorId) => {
|
||||
if (!confirm('Remove this sensor? The node will be marked for deployment sync.')) return
|
||||
const sensor = getSensor(nodeId, rawSensorId)
|
||||
if (!sensor) return
|
||||
|
||||
try {
|
||||
await api.delete(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors/${encodeURIComponent(rawSensorId)}`)
|
||||
fetchNodeDetails(nodeId)
|
||||
const compositeId = `${nodeId}:${rawSensorId}`
|
||||
if (selectedSensorId.value === compositeId) selectedSensorId.value = null
|
||||
} catch (err) {
|
||||
console.error('Failed to remove sensor:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSilence = async (nodeId, rawSensorId, targetState) => {
|
||||
const sensor = getSensor(nodeId, rawSensorId)
|
||||
if (!sensor) return
|
||||
|
||||
const previousState = patchSensor(sensor.id, { isSilenced: targetState })
|
||||
|
||||
try {
|
||||
await api.patch(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors/${encodeURIComponent(rawSensorId)}/silence`, {
|
||||
is_silenced: targetState,
|
||||
})
|
||||
} catch (err) {
|
||||
if (previousState) patchSensor(sensor.id, previousState)
|
||||
console.error('Failed to toggle sensor silence:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const silenceNode = async (nodeId) => {
|
||||
const nodeSensors = sensorsByNodeId.value[nodeId] || []
|
||||
if (!nodeSensors.length) return
|
||||
|
||||
const allSilenced = nodeSensors.every(s => s.isSilenced)
|
||||
const targetState = !allSilenced
|
||||
|
||||
const prevStates = new Map()
|
||||
nodeSensors.forEach(s => {
|
||||
const prev = patchSensor(s.id, { isSilenced: targetState })
|
||||
prevStates.set(s.id, prev)
|
||||
})
|
||||
|
||||
try {
|
||||
await Promise.all(nodeSensors.map(s =>
|
||||
api.patch(`/api/v1/nodes/${nodeId}/sensors/${encodeURIComponent(s.sensorId)}/silence`, {
|
||||
is_silenced: targetState,
|
||||
})
|
||||
))
|
||||
} catch (err) {
|
||||
nodeSensors.forEach(s => {
|
||||
patchSensor(s.id, prevStates.get(s.id))
|
||||
})
|
||||
console.error('Failed to silence node:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 8: Exports ---
|
||||
return {
|
||||
nodes, uptimeData, selectedNode, selectedSensor, activeTimeframe,
|
||||
overallUptime, nodeMap, sensorIndex,
|
||||
// State
|
||||
nodesById, sensorsById,
|
||||
selectedNodeId, selectedSensorId, activeTimeframe, uptimeData,
|
||||
pendingNodeActions, pendingSensorActions,
|
||||
// Aliases
|
||||
selectedNode, selectedSensor,
|
||||
|
||||
// Computed / Projection
|
||||
nodes, sensorsByNodeId, overallUptime,
|
||||
|
||||
// Getters
|
||||
getNode, getSensor,
|
||||
isNodeActionPending,
|
||||
|
||||
// Actions
|
||||
fetchFleet, fetchNodeDetails, fetchUptime, fetchManifests,
|
||||
selectTarget, clearSelection,
|
||||
createNode, deleteNode, updateNode,
|
||||
addSensor, updateSensor, removeSensor, toggleSilence, silenceNode,
|
||||
fetchCompose, generateCompose, syncNode,
|
||||
handleWsUpdate,
|
||||
normalizeSensor, normalizeNode,
|
||||
normalizeSensorData, normalizeNodeData,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,9 +245,9 @@ const closeDeployModal = () => {
|
||||
const handleSilenceNode = (nodeId) => fleetStore.silenceNode(nodeId)
|
||||
const handleForgetNode = (nodeId) => fleetStore.deleteNode(nodeId)
|
||||
|
||||
const handleOpenNodeDetail = (nodeId) => {
|
||||
fleetStore.selectedNode = nodeId
|
||||
appStore.setView('node-detail')
|
||||
const openNodeDetail = (nodeId) => {
|
||||
fleetStore.selectTarget(nodeId)
|
||||
appStore.navigate('node-detail')
|
||||
}
|
||||
|
||||
// --- Copy Animation (ephemeral UI) ---
|
||||
@@ -263,6 +263,12 @@ const handleCopy = async (id, text) => {
|
||||
console.error('Failed to copy text', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenNodeDetail = (nodeId) => {
|
||||
fleetStore.selectTarget(nodeId)
|
||||
appStore.setView('node-detail')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -334,7 +340,7 @@ const handleCopy = async (id, text) => {
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-3 2xl:grid-cols-4 gap-5 auto-rows-max">
|
||||
|
||||
<BaseWidget v-for="node in displayNodes" :key="node.id" class="flex flex-col h-full min-h-[280px]">
|
||||
<BaseWidget v-for="node in displayNodes" :key="node.id" class="flex flex-col h-full min-h-[280px] transition-all duration-[var(--duration-fast)]" :class="{ 'opacity-50 pointer-events-none': fleetStore.isNodeActionPending(node.id, 'deleting') }">
|
||||
|
||||
<template #header>
|
||||
<div class="flex items-start justify-between w-full">
|
||||
@@ -376,7 +382,7 @@ const handleCopy = async (id, text) => {
|
||||
{{ node.isSilenced ? 'Unsilence Node' : 'Silence Node' }}
|
||||
</button>
|
||||
<button @click="handleForgetNode(node.id)" class="w-full text-left px-3 py-2 text-sm text-danger-text hover:bg-danger-bg transition-colors border-t border-border-default mt-1 pt-2">
|
||||
Delete Node
|
||||
<span v-if="fleetStore.isNodeActionPending(node.id, 'deleting')">Deleting...</span><span v-else>Delete Node</span>
|
||||
</button>
|
||||
</BaseMeatballMenu>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ const fleetStore = useFleetStore()
|
||||
const eventsStore = useEventsStore()
|
||||
const { config } = useConfig()
|
||||
|
||||
const selectedNodeId = computed(() => fleetStore.selectedNode)
|
||||
const selectedNodeId = computed(() => fleetStore.selectedNodeId)
|
||||
|
||||
// --- NODE STATE ---
|
||||
const node = computed(() => fleetStore.getNode(selectedNodeId.value))
|
||||
@@ -258,18 +258,18 @@ const sortedEnvVars = computed(() => {
|
||||
// --- SENSOR ACTIONS (delegated to store) ---
|
||||
|
||||
const handleToggleSensorSilence = async (sensor) => {
|
||||
if (!node.value?.id || !sensor.id) return
|
||||
if (!node.value?.id || !sensor.sensorId) return
|
||||
try {
|
||||
await fleetStore.toggleSilence(node.value.id, sensor.id, !sensor.isSilenced)
|
||||
await fleetStore.toggleSilence(node.value.id, sensor.sensorId, !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
|
||||
if (!node.value?.id || !sensor.sensorId) return
|
||||
try {
|
||||
await fleetStore.removeSensor(node.value.id, sensor.id)
|
||||
await fleetStore.removeSensor(node.value.id, sensor.sensorId)
|
||||
} catch (err) {
|
||||
alert('Could not remove sensor. Please try again.')
|
||||
}
|
||||
@@ -407,7 +407,7 @@ const editSensor = (installedSensor) => {
|
||||
const apiKey = node.value?.apiKey
|
||||
selectedSensor.value = manifest
|
||||
isEditingSensor.value = true
|
||||
editingSensorId.value = installedSensor.id
|
||||
editingSensorId.value = installedSensor.sensorId
|
||||
activeTab.value = 'config'
|
||||
envVarValues.value = {}
|
||||
envVarValues.value['HW_SEVERITY'] = 'critical'
|
||||
@@ -671,7 +671,7 @@ const applyHighlighting = () => {
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-h mb-4 mt-2">Deployed Sensors</h3>
|
||||
<div v-if="node.installedSensors.length > 0" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<div v-if="node.installedSensors?.length > 0" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
<div v-for="sensor in node.installedSensors" :key="sensor.id" class="bg-bg-surface border border-border-default rounded-lg p-4 flex flex-col group hover:border-text-m transition-colors shadow-sm relative overflow-hidden">
|
||||
|
||||
<div class="absolute top-0 left-0 right-0 h-1 transition-colors" :class="sensor.status === 'up' ? 'bg-success-main' : 'bg-danger-main'"></div>
|
||||
|
||||
Reference in New Issue
Block a user