refactored frontend Fleet store architecture ( fleet.js ), and updated components that used it to match new implementation.

This commit is contained in:
AndReicscs
2026-05-25 17:54:13 +00:00
parent ac718802ab
commit d0dd7d42cf
10 changed files with 656 additions and 523 deletions
+1 -1
View File
@@ -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>
+191
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
})
})
+11 -5
View File
@@ -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>
+7 -7
View File
@@ -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>