-
+
+
+
diff --git a/Hub/ui/src/api/client.js b/Hub/ui/src/api/client.js
new file mode 100644
index 0000000..94373fc
--- /dev/null
+++ b/Hub/ui/src/api/client.js
@@ -0,0 +1,48 @@
+// src/api/client.js
+
+class ApiError extends Error {
+ constructor(message, status) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ }
+}
+
+const handleResponse = async (res) => {
+ if (!res.ok) {
+ const text = await res.text().catch(() => '')
+ throw new ApiError(text || `HTTP ${res.status}`, res.status)
+ }
+ return res
+}
+
+export const api = {
+ async get(url) {
+ return handleResponse(await fetch(url))
+ },
+
+ async post(url, body) {
+ return handleResponse(await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }))
+ },
+
+ async patch(url, body) {
+ return handleResponse(await fetch(url, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }))
+ },
+
+ async delete(url) {
+ return handleResponse(await fetch(url, { method: 'DELETE' }))
+ },
+
+ // For requests with custom headers (e.g. Authorization) or non-JSON bodies
+ async request(url, options = {}) {
+ return handleResponse(await fetch(url, options))
+ },
+}
\ No newline at end of file
diff --git a/Hub/ui/src/services/ws.js b/Hub/ui/src/services/ws.js
index b8be815..55d5699 100644
--- a/Hub/ui/src/services/ws.js
+++ b/Hub/ui/src/services/ws.js
@@ -30,6 +30,10 @@ export class HoneyWireWS {
onSilenceSensor: null,
onReconnect: null,
onSyncCharts: null,
+ onNewNode: null,
+ onUpdateNode: null,
+ onDeleteNode: null,
+ onNodeSynced: null,
}
}
@@ -138,6 +142,28 @@ export class HoneyWireWS {
this.callbacks.onSilenceSensor(data.payload)
}
break
+ case 'NEW_NODE':
+ if (this.callbacks.onNewNode) {
+ this.callbacks.onNewNode(data.payload)
+ }
+ break
+
+ case 'UPDATE_NODE':
+ if (this.callbacks.onUpdateNode) {
+ this.callbacks.onUpdateNode(data.payload)
+ }
+ break
+
+ case 'DELETE_NODE':
+ if (this.callbacks.onDeleteNode) {
+ this.callbacks.onDeleteNode(data.payload)
+ }
+ break
+ case 'NODE_SYNCED':
+ if (this.callbacks.onNodeSynced) {
+ this.callbacks.onNodeSynced(data.payload)
+ }
+ break
default:
console.warn(`WebSocket: Unknown message type: ${data.type}`)
diff --git a/Hub/ui/src/stores/app.js b/Hub/ui/src/stores/app.js
index 6930eb9..0f7a548 100644
--- a/Hub/ui/src/stores/app.js
+++ b/Hub/ui/src/stores/app.js
@@ -1,12 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
-
-/**
- * App Store (Global UI State)
- *
- * Manages system-wide UI settings: armed state, theme, archive view, sidebar, and current view.
- * Auth checking is handled in App.vue, not here.
- */
+import { api } from '../api/client'
export const useAppStore = defineStore('app', () => {
// --- STATE ---
@@ -18,12 +12,13 @@ export const useAppStore = defineStore('app', () => {
const activeTimeframe = ref('24H')
const velocityTimeframe = ref('24H')
- // --- ACTIONS ---
+ // Auth/setup state (new, not used by old components)
+ const isAuthenticated = ref(false)
+ const requiresSetup = ref(false)
+ const isInitialized = ref(false)
+
+ // --- ACTIONS: UI ---
- /**
- * Toggle the armed state of the system
- * Makes a PATCH request to /api/v1/system/state
- */
const toggleArmed = async () => {
const next = !isArmed.value
const previous = isArmed.value
@@ -43,9 +38,6 @@ export const useAppStore = defineStore('app', () => {
}
}
- /**
- * Toggle between light and dark theme
- */
const toggleTheme = () => {
const html = document.documentElement
if (html.classList.contains('dark')) {
@@ -57,10 +49,26 @@ export const useAppStore = defineStore('app', () => {
}
}
- /**
- * Logout and redirect to login page
- * Makes a POST request to /logout
- */
+ const toggleSidebar = () => { sidebarOpen.value = !sidebarOpen.value }
+ const setView = (view) => { currentView.value = view }
+ const toggleArchive = () => { viewingArchive.value = !viewingArchive.value }
+
+ // --- ACTIONS: AUTH ---
+
+ const login = async (password) => {
+ try {
+ await api.post('/login', { password })
+ // Do NOT set isAuthenticated here.
+ // App.vue controls when to reveal the authenticated shell
+ // (after loadAppData completes), so the Login component
+ // stays mounted long enough to emit 'login-success'.
+ return { success: true }
+ } catch (err) {
+ isAuthenticated.value = false
+ return { success: false, status: err.status || 0 }
+ }
+ }
+
const logout = async () => {
try {
await fetch('/logout', { method: 'POST' })
@@ -70,9 +78,58 @@ export const useAppStore = defineStore('app', () => {
}
}
- /**
- * Fetch system version and state from backend (called during init)
- */
+ // --- ACTIONS: SETUP ---
+
+ const completeSetup = async (password, hubEndpoint, hubKey) => {
+ try {
+ await api.post('/api/v1/setup', {
+ password,
+ hub_endpoint: hubEndpoint,
+ hub_key: hubKey,
+ })
+ requiresSetup.value = false
+ isAuthenticated.value = true
+ return { success: true }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+ }
+
+ // --- ACTIONS: SECURITY ---
+
+ const changePassword = async (currentPassword, newPassword) => {
+ try {
+ await api.patch('/api/v1/system/password', {
+ current_password: currentPassword,
+ new_password: newPassword,
+ })
+ return { success: true }
+ } catch (err) {
+ if (err.status === 401) {
+ return { success: false, error: 'Incorrect current password.' }
+ }
+ return { success: false, error: err.message || 'Failed to update password.' }
+ }
+ }
+
+ const factoryReset = async (password) => {
+ try {
+ await api.request('/api/v1/system/reset', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password }),
+ })
+ return { success: true }
+ } catch (err) {
+ if (err.status === 401) {
+ return { success: false, error: 'Incorrect password.' }
+ }
+ return { success: false, error: err.message || 'Factory reset failed.' }
+ }
+ }
+
+ // --- ACTIONS: BOOTSTRAP ---
+
const checkSetupStatus = async () => {
try {
const [stateRes, verRes] = await Promise.all([
@@ -87,8 +144,28 @@ export const useAppStore = defineStore('app', () => {
}
}
+ const checkSystemState = async () => {
+ try {
+ await api.get('/api/v1/system/state')
+ return true
+ } catch (e) {
+ return false
+ }
+ }
+
+ const checkRequiresSetup = async () => {
+ try {
+ const res = await api.get('/api/v1/setup/status')
+ const data = await res.json()
+ return data.requires_setup || false
+ } catch (e) {
+ console.error('Failed to check setup status:', e)
+ return false
+ }
+ }
+
return {
- // State
+ // State — ALL original properties preserved
isArmed,
version,
viewingArchive,
@@ -96,10 +173,33 @@ export const useAppStore = defineStore('app', () => {
currentView,
activeTimeframe,
velocityTimeframe,
- // Actions
+
+ // Auth/setup state (new)
+ isAuthenticated,
+ requiresSetup,
+ isInitialized,
+
+ // Actions: UI (original signatures preserved)
toggleArmed,
toggleTheme,
+ toggleSidebar,
+ setView,
+ toggleArchive,
+
+ // Actions: auth (new)
+ login,
logout,
+
+ // Actions: setup (new)
+ completeSetup,
+
+ // Actions: security (new)
+ changePassword,
+ factoryReset,
+
+ // Actions: bootstrap (original + new)
checkSetupStatus,
+ checkSystemState,
+ checkRequiresSetup,
}
-})
+})
\ No newline at end of file
diff --git a/Hub/ui/src/stores/fleet.js b/Hub/ui/src/stores/fleet.js
index dc5d623..165d226 100644
--- a/Hub/ui/src/stores/fleet.js
+++ b/Hub/ui/src/stores/fleet.js
@@ -1,15 +1,112 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
+import { api } from '../api/client'
export const useFleetStore = defineStore('fleet', () => {
// --- STATE ---
- const nodes = ref([]) // V2: Sensors are nested inside nodes!
+ const nodes = ref([])
const uptimeData = ref([])
const selectedNode = ref(null)
const selectedSensor = ref(null)
const activeTimeframe = ref('24H')
+ // --- NORMALIZATION ---
+
+ const normalizeSensor = (raw) => {
+ if (!raw) return null
+ return {
+ ...raw,
+ id: raw.id || raw.sensor_id || raw.name,
+ name: raw.name || raw.sensor_id || raw.id,
+ display: raw.display || raw.custom_name || raw.name || raw.sensor_id || raw.id,
+ status: raw.status || 'down',
+ events24h: raw.events24h ?? raw.events_24h ?? 0,
+ isSilenced: raw.isSilenced ?? raw.is_silenced ?? false,
+ osi: raw.osi_layer || raw.osi || 'Sensor',
+ icon: raw.icon || raw.icon_svg || 'M12 12h0',
+ envVars: raw.envVars || raw.env_vars || {},
+ metadata: raw.metadata || {},
+ lastHeartbeat: raw.lastHeartbeat || raw.last_heartbeat || null,
+ }
+ }
+
+ const normalizeNode = (raw) => {
+ if (!raw) return null
+ return {
+ ...raw,
+ id: raw.id,
+ alias: raw.alias || 'Unnamed Node',
+ status: raw.status || 'unknown',
+ publicIp: raw.publicIp || raw.public_ip || null,
+ privateIp: raw.privateIp || raw.private_ip || null,
+ tags: raw.tags || [],
+ apiKey: raw.apiKey || raw.api_key || null,
+ hasPendingConfig: raw.hasPendingConfig ?? raw.pending_config ?? raw.has_pending_config ?? false,
+ activeRevision: raw.activeRevision || raw.active_revision || '',
+ desiredRevision: raw.desiredRevision || raw.desired_revision || '',
+ lastEvent: raw.lastEvent || raw.last_event || 'Never',
+ lastHeartbeat: raw.lastHeartbeat || raw.last_heartbeat || null,
+ installedSensors: (raw.installedSensors || raw.installed_sensors || []).map(normalizeSensor),
+ }
+ }
+
+ // --- MERGE HELPERS ---
+
+ const mergeSensor = (existing, incoming) => {
+ Object.assign(existing, incoming)
+ }
+
+ const mergeNode = (existing, incoming) => {
+ const incomingSensors = incoming.installedSensors || []
+ const existingSensors = existing.installedSensors || []
+
+ const incomingSensorIds = new Set(incomingSensors.map(s => s.id))
+
+ incomingSensors.forEach(newSensor => {
+ const existingSensor = existingSensors.find(s => s.id === newSensor.id)
+ if (existingSensor) {
+ mergeSensor(existingSensor, newSensor)
+ } else {
+ existingSensors.push(newSensor)
+ }
+ })
+
+ for (let i = existingSensors.length - 1; i >= 0; i--) {
+ if (!incomingSensorIds.has(existingSensors[i].id)) {
+ existingSensors.splice(i, 1)
+ }
+ }
+
+ Object.assign(existing, incoming, { installedSensors: existingSensors })
+ }
+
+ // --- INDEXED ACCESS ---
+
+ const nodeMap = computed(() => {
+ const map = {}
+ for (const node of nodes.value) {
+ map[node.id] = node
+ }
+ return map
+ })
+
+ const sensorIndex = computed(() => {
+ const map = {}
+ for (const node of nodes.value) {
+ const sensors = {}
+ for (const sensor of (node.installedSensors || [])) {
+ sensors[sensor.id] = sensor
+ }
+ map[node.id] = sensors
+ }
+ return map
+ })
+
+ const getNode = (nodeId) => nodeMap.value[nodeId] || null
+ const getSensor = (nodeId, sensorId) => sensorIndex.value[nodeId]?.[sensorId] || null
+
// --- GETTERS ---
+
const overallUptime = computed(() => {
if (!uptimeData.value || uptimeData.value.length === 0) return '0.0%'
let validBlocks = 0
@@ -27,148 +124,416 @@ export const useFleetStore = defineStore('fleet', () => {
return validBlocks === 0 ? '100.0%' : ((upBlocks / validBlocks) * 100).toFixed(1) + '%'
})
- // --- ACTIONS ---
+ // --- ACTIONS: FETCH ---
+
const fetchFleet = async () => {
try {
- const res = await fetch('/api/v1/nodes')
- if (res.ok) {
- nodes.value = await res.json() || []
+ const res = await api.get('/api/v1/nodes')
+ const raw = await res.json() || []
+ const incoming = (Array.isArray(raw) ? raw : raw.nodes || []).map(normalizeNode)
+
+ const incomingIds = new Set(incoming.map(n => n.id))
+
+ incoming.forEach(newNode => {
+ const existing = getNode(newNode.id)
+ if (existing) {
+ mergeNode(existing, newNode)
+ } else {
+ nodes.value.push(newNode)
+ }
+ })
+
+ for (let i = nodes.value.length - 1; i >= 0; i--) {
+ if (!incomingIds.has(nodes.value[i].id)) {
+ nodes.value.splice(i, 1)
+ }
}
} catch (e) {
console.error('Failed to fetch fleet data', e)
}
}
- const fetchUptime = async (timeframe) => {
- const target = timeframe || activeTimeframe.value;
+ const fetchNodeDetails = async (nodeId) => {
try {
- const res = await fetch(`/api/v1/uptime?timeframe=${target}`)
- if (res.ok) uptimeData.value = await res.json() || []
+ const res = await api.get(`/api/v1/nodes/${encodeURIComponent(nodeId)}`)
+ const raw = await res.json()
+ const normalized = normalizeNode(raw)
+
+ const existing = getNode(nodeId)
+ if (existing) {
+ mergeNode(existing, normalized)
+ return existing
+ } else {
+ nodes.value.push(normalized)
+ return normalized
+ }
} catch (e) {
- console.error('Failed to fetch uptime', e)
+ console.error('Failed to fetch node details:', e)
+ return null
}
}
+ const fetchUptime = async (timeframe) => {
+ const target = timeframe || activeTimeframe.value
+ try {
+ const res = await api.get(`/api/v1/uptime?timeframe=${target}`)
+ uptimeData.value = await res.json() || []
+ } catch (e) {
+ console.error('Failed to fetch uptime', e)
+ }
+ }
+
+ const fetchManifests = async () => {
+ try {
+ const res = await api.get('/api/v1/manifests')
+ return await res.json()
+ } catch (err) {
+ console.error('Failed to fetch manifests:', err)
+ throw err
+ }
+ }
+
+ // --- ACTIONS: SELECTION ---
+
+ const clearSelection = () => {
+ selectedNode.value = null
+ selectedSensor.value = null
+ }
+
const selectTarget = (nodeId, sensorId = null) => {
- if (selectedNode.value === nodeId && selectedSensor.value === sensorId) {
- selectedNode.value = null
- selectedSensor.value = null
- } else if (sensorId === null && selectedNode.value === nodeId && selectedSensor.value !== null) {
- selectedSensor.value = null
- } else if (sensorId === null && selectedNode.value === nodeId && selectedSensor.value === null) {
- selectedNode.value = null
- } else {
- selectedNode.value = nodeId
- selectedSensor.value = sensorId
+ const sameNode = selectedNode.value === nodeId
+ const sameSensor = selectedSensor.value === sensorId
+
+ if (sameNode && sameSensor) {
+ clearSelection()
+ return
+ }
+
+ selectedNode.value = nodeId
+ selectedSensor.value = sensorId
+ }
+
+ // --- ACTIONS: NODE MUTATIONS ---
+
+ const createNode = async (alias, tags = []) => {
+ try {
+ const res = await api.post('/api/v1/nodes', { alias, tags })
+ const data = await res.json()
+
+ // Optimistic add — partial node, background fetch fills details
+ const partialNode = normalizeNode({
+ id: data.node_id || data.nodeId,
+ alias,
+ tags,
+ status: 'pending',
+ apiKey: data.api_key || data.apiKey,
+ installedSensors: [],
+ })
+ nodes.value.push(partialNode)
+
+ // Background refresh for full details
+ fetchNodeDetails(partialNode.id)
+
+ return {
+ nodeId: data.node_id || data.nodeId,
+ apiKey: data.api_key || data.apiKey,
+ }
+ } catch (err) {
+ console.error('Failed to create node:', err)
+ throw err
}
}
const deleteNode = async (nodeId) => {
if (!confirm(`Delete Node "${nodeId}" and ALL of its underlying sensors?`)) return
+
+ const previousIdx = nodes.value.findIndex(n => n.id === nodeId)
+ const previous = previousIdx !== -1 ? nodes.value[previousIdx] : null
+
+ if (previousIdx !== -1) nodes.value.splice(previousIdx, 1)
+ if (selectedNode.value === nodeId) clearSelection()
+
try {
- const res = await fetch(`/api/v1/nodes/${nodeId}`, { method: 'DELETE' })
- if (!res.ok) throw new Error('Delete failed')
-
- if (selectedNode.value === nodeId) {
- selectedNode.value = null
- selectedSensor.value = null
- }
- await fetchFleet()
+ await api.delete(`/api/v1/nodes/${nodeId}`)
} catch (err) {
console.error('Failed to delete node:', err)
+ if (previous && previousIdx !== -1) {
+ nodes.value.splice(previousIdx, 0, previous)
+ }
+ fetchFleet()
}
}
- const deleteSensor = async (nodeId, sensorId) => {
- if (!confirm('Remove this sensor? The node will be marked for deployment sync.')) return
+ const updateNode = async (nodeId, payload) => {
+ const node = getNode(nodeId)
+ if (!node) return
+
+ const previous = {
+ alias: node.alias,
+ tags: [...node.tags],
+ publicIp: node.publicIp,
+ privateIp: node.privateIp,
+ }
+
+ if (payload.alias !== undefined) node.alias = payload.alias
+ if (payload.tags !== undefined) node.tags = payload.tags
+ if (payload.publicIp !== undefined) node.publicIp = payload.publicIp
+ if (payload.privateIp !== undefined) node.privateIp = payload.privateIp
+
try {
- const res = await fetch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}`, { method: 'DELETE' })
- if (!res.ok) throw new Error('Delete failed')
-
- if (selectedSensor.value === sensorId) selectedSensor.value = null
- await fetchFleet()
+ await api.patch(`/api/v1/nodes/${nodeId}`, payload)
} catch (err) {
- console.error('Failed to delete sensor:', err)
+ Object.assign(node, previous)
+ console.error('Failed to update node:', err)
+ throw err
+ }
+ }
+
+ // --- ACTIONS: SENSOR MUTATIONS ---
+
+ const addSensor = async (nodeId, { sensorId, customName, configValues }) => {
+ const node = getNode(nodeId)
+ if (!node) return
+
+ const optimisticSensor = normalizeSensor({
+ id: sensorId,
+ name: customName || sensorId,
+ display: customName || sensorId,
+ status: 'pending',
+ events24h: 0,
+ isSilenced: false,
+ osi: 'Sensor',
+ icon: 'M12 12h0',
+ lastHeartbeat: null,
+ })
+
+ node.installedSensors = node.installedSensors || []
+ node.installedSensors.push(optimisticSensor)
+ node.hasPendingConfig = true
+
+ try {
+ await api.post(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors`, {
+ sensor_id: sensorId,
+ custom_name: customName || sensorId,
+ config_values: configValues,
+ })
+ fetchNodeDetails(nodeId)
+ } catch (err) {
+ const idx = node.installedSensors.findIndex(s => s.id === sensorId)
+ if (idx !== -1) node.installedSensors.splice(idx, 1)
+ node.hasPendingConfig = false
+ console.error('Failed to add sensor:', err)
+ throw err
+ }
+ }
+
+ const removeSensor = async (nodeId, sensorId) => {
+ if (!confirm('Remove this sensor? The node will be marked for deployment sync.')) return
+
+ const node = getNode(nodeId)
+ if (!node) return
+
+ const sensorIdx = node.installedSensors?.findIndex(s => s.id === sensorId) ?? -1
+ const previous = sensorIdx !== -1 ? { ...node.installedSensors[sensorIdx] } : null
+
+ if (sensorIdx !== -1) node.installedSensors.splice(sensorIdx, 1)
+ node.hasPendingConfig = true
+
+ if (selectedSensor.value === sensorId) selectedSensor.value = null
+
+ try {
+ await api.delete(`/api/v1/nodes/${encodeURIComponent(nodeId)}/sensors/${encodeURIComponent(sensorId)}`)
+ } catch (err) {
+ if (previous && sensorIdx !== -1) {
+ node.installedSensors.splice(sensorIdx, 0, previous)
+ node.hasPendingConfig = false
+ }
+ console.error('Failed to remove sensor:', err)
+ throw err
}
}
const toggleSilence = async (nodeId, sensorId, targetState) => {
+ const sensor = getSensor(nodeId, sensorId)
+ if (!sensor) return
+
+ const previous = sensor.isSilenced
+ sensor.isSilenced = targetState
+
try {
- const res = await fetch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}/silence`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ is_silenced: targetState }),
+ await api.patch(`/api/v1/nodes/${nodeId}/sensors/${sensorId}/silence`, {
+ is_silenced: targetState,
})
- if (!res.ok) throw new Error('Silence toggle failed')
- await fetchFleet()
} catch (err) {
+ sensor.isSilenced = previous
console.error('Failed to toggle sensor silence:', err)
+ throw err
}
}
const silenceNode = async (nodeId) => {
- const node = nodes.value.find(n => n.id === nodeId)
- if (!node || !node.installedSensors || !node.installedSensors.length) return
+ const node = getNode(nodeId)
+ if (!node?.installedSensors?.length) return
const allSilenced = node.installedSensors.every(s => s.isSilenced)
const targetState = !allSilenced
+ const previousStates = node.installedSensors.map(s => s.isSilenced)
+ node.installedSensors.forEach(s => { s.isSilenced = targetState })
+
try {
- await Promise.all(node.installedSensors.map(s =>
- fetch(`/api/v1/nodes/${nodeId}/sensors/${s.id}/silence`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ is_silenced: targetState })
- })
+ await Promise.all(node.installedSensors.map(s =>
+ api.patch(`/api/v1/nodes/${nodeId}/sensors/${s.id}/silence`, {
+ is_silenced: targetState,
+ })
))
- await fetchFleet()
} catch (err) {
- console.error("Failed to silence node:", err)
+ node.installedSensors.forEach((s, i) => { s.isSilenced = previousStates[i] })
+ console.error('Failed to silence node:', err)
+ throw err
}
}
+ // --- ACTIONS: COMPOSE / SYNC ---
+
+ const fetchCompose = async (apiKey) => {
+ try {
+ const res = await api.request('/api/v1/nodes/compose', {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ })
+ return await res.text()
+ } catch (err) {
+ console.error('Failed to fetch compose:', err)
+ throw err
+ }
+ }
+
+ const generateCompose = async (payload) => {
+ try {
+ const res = await api.post('/api/v1/compose/generate', payload)
+ return await res.text()
+ } catch (err) {
+ console.error('Failed to generate compose:', err)
+ throw err
+ }
+ }
+
+ const syncNode = async (nodeId) => {
+ const node = getNode(nodeId)
+ if (!node?.apiKey) {
+ throw new Error('Unable to sync: missing node API key')
+ }
+
+ const composeYaml = await fetchCompose(node.apiKey)
+ node.hasPendingConfig = false
+ fetchNodeDetails(nodeId)
+
+ return composeYaml
+ }
+
+ // --- WEBSOCKET HANDLER ---
+
const handleWsUpdate = (type, payload) => {
- // For V2, the safest and cleanest way to handle state changes (since sensors are nested)
- // is to just refetch the fleet on critical events, or do targeted updates.
- if (['NODE_SYNCED', 'UPDATE_NODE', 'DELETE_NODE', 'SILENCE_SENSOR'].includes(type)) {
- fetchFleet()
- } else if (type === 'NEW_EVENT') {
- fetchFleet() // Refetches to update the 24h event counts
- } else if (type === 'SENSOR_HEARTBEAT') {
- const node = nodes.value.find(n => n.id === payload.node_id)
+ if (type === 'SENSOR_HEARTBEAT') {
+ const node = getNode(payload.node_id)
if (node) {
node.lastHeartbeat = payload.timestamp
node.status = 'up'
- const sensor = (node.installedSensors || []).find(s => s.id === payload.sensor_id)
+ const sensor = getSensor(payload.node_id, payload.sensor_id)
if (sensor) {
sensor.lastHeartbeat = payload.timestamp
sensor.status = 'up'
}
}
+ return
}
- }
- const updateNode = async (nodeId, payload) => {
- try {
- const res = await fetch(`/api/v1/nodes/${nodeId}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload)
- })
- if (!res.ok) throw new Error('Failed to update node')
-
- // Sync state from backend to ensure UI matches reality
- await fetchFleet()
- } catch (err) {
- console.error('Failed to update node:', err)
- throw err // Throw back to component for UI rollback
+ if (type === 'SILENCE_SENSOR') {
+ const sensor = getSensor(payload.node_id, payload.sensor_id)
+ if (sensor) {
+ sensor.isSilenced = payload.is_silenced ?? payload.isSilenced ?? sensor.isSilenced
+ }
+ return
+ }
+
+ if (type === 'NEW_SENSOR') {
+ const node = getNode(payload.node_id)
+ if (node && payload.sensor) {
+ const sensorId = payload.sensor.id || payload.sensor.sensor_id
+ const exists = getSensor(payload.node_id, sensorId)
+ if (!exists) {
+ node.installedSensors = node.installedSensors || []
+ node.installedSensors.push(normalizeSensor(payload.sensor))
+ }
+ node.hasPendingConfig = true
+ }
+ return
+ }
+
+ if (type === 'DELETE_SENSOR') {
+ const node = getNode(payload.node_id)
+ if (node && payload.sensor_id) {
+ const idx = node.installedSensors?.findIndex(s => s.id === payload.sensor_id) ?? -1
+ if (idx !== -1) node.installedSensors.splice(idx, 1)
+ node.hasPendingConfig = true
+ }
+ return
+ }
+
+ if (type === 'NODE_SYNCED') {
+ const node = getNode(payload.node_id)
+ if (node) {
+ node.hasPendingConfig = false
+ if (payload.active_revision) node.activeRevision = payload.active_revision
+ if (payload.desired_revision !== undefined) node.desiredRevision = payload.desired_revision
+ }
+ return
+ }
+
+ if (type === 'UPDATE_NODE') {
+ const node = getNode(payload.id)
+ if (node) mergeNode(node, normalizeNode(payload))
+ return
+ }
+
+ if (type === 'DELETE_NODE') {
+ const idx = nodes.value.findIndex(n => n.id === payload.node_id)
+ if (idx !== -1) nodes.value.splice(idx, 1)
+ if (selectedNode.value === payload.node_id) clearSelection()
+ return
+ }
+
+ if (type === 'NEW_NODE') {
+ const exists = getNode(payload.id)
+ if (!exists) nodes.value.push(normalizeNode(payload))
+ return
+ }
+
+ if (type === 'NEW_EVENT') {
+ if (payload.node_id && payload.sensor_id) {
+ const node = getNode(payload.node_id)
+ if (node) {
+ const sensor = getSensor(payload.node_id, payload.sensor_id)
+ if (sensor) sensor.events24h = (sensor.events24h || 0) + 1
+ node.lastEvent = 'Just now'
+ }
+ } else {
+ fetchFleet()
+ }
+ return
}
}
return {
nodes, uptimeData, selectedNode, selectedSensor, activeTimeframe,
- overallUptime,
- fetchFleet, fetchUptime, selectTarget, deleteNode, deleteSensor, toggleSilence, silenceNode, handleWsUpdate,
- updateNode,
+ overallUptime, nodeMap, sensorIndex,
+ getNode, getSensor,
+ fetchFleet, fetchNodeDetails, fetchUptime, fetchManifests,
+ selectTarget, clearSelection,
+ createNode, deleteNode, updateNode,
+ addSensor, removeSensor, toggleSilence, silenceNode,
+ fetchCompose, generateCompose, syncNode,
+ handleWsUpdate,
+ normalizeSensor, normalizeNode,
}
})
\ No newline at end of file
diff --git a/Hub/ui/src/views/Dashboard.vue b/Hub/ui/src/views/Dashboard.vue
index aad47bb..b0a9bbe 100644
--- a/Hub/ui/src/views/Dashboard.vue
+++ b/Hub/ui/src/views/Dashboard.vue
@@ -16,37 +16,21 @@ const currentHost = ref(window.location.host)
const generateToken = async () => {
const alias = window.prompt('Enter a friendly alias for the new node', `New Node ${Date.now()}`)
- if (!alias) {
- return
- }
+ if (!alias) return
isGeneratingToken.value = true
try {
- const response = await fetch('/api/v1/nodes', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ alias, tags: [] }),
- })
-
- if (!response.ok) {
- const errText = await response.text()
- throw new Error(errText || 'Failed to create node')
- }
-
- const data = await response.json()
- provisionToken.value = data.api_key
- provisionNodeId.value = data.node_id
+ const result = await fleetStore.createNode(alias)
+ provisionToken.value = result.apiKey
+ provisionNodeId.value = result.nodeId
showProvisionModal.value = true
- fleetStore.fetchFleet()
} catch (err) {
- console.error('Create node error:', err)
alert('Failed to create node. Please try again.')
} finally {
isGeneratingToken.value = false
}
}
-// Added Quick-Copy functionality for the provision command
const copyCommand = () => {
const cmd = `./wizard --link http://${currentHost.value} ${provisionToken.value}`
navigator.clipboard.writeText(cmd)
diff --git a/Hub/ui/src/views/FleetView.vue b/Hub/ui/src/views/FleetView.vue
index 5083f40..dcefb6e 100644
--- a/Hub/ui/src/views/FleetView.vue
+++ b/Hub/ui/src/views/FleetView.vue
@@ -1,6 +1,5 @@
diff --git a/Hub/ui/src/views/NodeDetailView.vue b/Hub/ui/src/views/NodeDetailView.vue
index 46c3e70..c0c1431 100644
--- a/Hub/ui/src/views/NodeDetailView.vue
+++ b/Hub/ui/src/views/NodeDetailView.vue
@@ -17,34 +17,27 @@ const { config } = useConfig()
const selectedNodeId = computed(() => fleetStore.selectedNode)
-// --- 1. NODE STATE ---
-const node = ref({
- id: null,
- alias: 'Loading node...',
- status: 'unknown',
- publicIp: null,
- privateIp: null,
- apiKey: null,
- tags: [],
- hasPendingConfig: false,
- lastEvent: 'Unknown',
- installedSensors: [],
-})
+// --- NODE STATE ---
+// Single source of truth: the live reactive object from the store.
+const node = computed(() => fleetStore.getNode(selectedNodeId.value))
const maxSensorEvents = computed(() => {
- if (!node.value.installedSensors.length) return 1
- return Math.max(...node.value.installedSensors.map(s => s.events24h || 0), 1)
+ if (!node.value?.installedSensors?.length) return 1
+ return Math.max(...node.value.installedSensors.map(s => s.events24h || 0), 1)
})
const sortedInstalledSensors = computed(() => {
- return [...(node.value.installedSensors || [])].sort((a, b) => (b.events24h || 0) - (a.events24h || 0))
+ return [...(node.value?.installedSensors || [])]
+ .sort((a, b) => (b.events24h || 0) - (a.events24h || 0))
})
-const isLoading = ref(true)
+// --- SENSOR CATALOG STATE ---
+const isManifestLoading = ref(true)
const fetchError = ref(false)
-
-const selectedSensor = ref(null)
const sensors = ref([])
+
+// --- SENSOR MODAL STATE ---
+const selectedSensor = ref(null)
const activeTab = ref('readme')
const envVarValues = ref({})
const activeEnvVar = ref(null)
@@ -52,365 +45,287 @@ const isSeverityOpen = ref(false)
const rawCompose = ref('')
const highlightedCompose = ref('')
const composePre = ref(null)
+
+// --- MODAL STATE ---
const showKeyModal = ref(false)
const showSyncModal = ref(false)
const syncComposeYaml = ref('')
-
-const severityOptions = [
- { value: 'info', label: 'Info', textClass: 'text-info', hoverClass: 'hover:bg-info/10 hover:text-info' },
- { value: 'low', label: 'Low', textClass: 'text-low', hoverClass: 'hover:bg-low/10 hover:text-low' },
- { value: 'medium', label: 'Medium', textClass: 'text-medium', hoverClass: 'hover:bg-medium/10 hover:text-medium' },
- { value: 'high', label: 'High', textClass: 'text-high', hoverClass: 'hover:bg-high/10 hover:text-high' },
- { value: 'critical', label: 'Critical', textClass: 'text-critical', hoverClass: 'hover:bg-critical/10 hover:text-critical' }
-]
-
-const currentSeverity = computed(() => severityOptions.find(s => s.value === envVarValues.value['HW_SEVERITY']) || severityOptions[3])
-const toggleSeverity = () => { isSeverityOpen.value = !isSeverityOpen.value; activeEnvVar.value = isSeverityOpen.value ? 'HW_SEVERITY' : null }
-const closeSeverity = () => { isSeverityOpen.value = false; activeEnvVar.value = null }
-const selectSeverity = (val) => { envVarValues.value['HW_SEVERITY'] = val; closeSeverity() }
-
-const getUIDefault = (def) => {
- if (!def) return ''
- if (!def.includes('{{')) return def
- const elseMatch = def.match(/\{\{\s*else\s*\}\}(.*?)\{\{\s*end\s*\}\}/)
- if (elseMatch) return elseMatch[1].trim()
- const funcMatch = def.match(/\{\{\s*[a-zA-Z]+\s+([0-9]+)\s*\}\}/)
- if (funcMatch) return funcMatch[1].trim()
- return ''
-}
-
-const coreVars = ['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SENSOR_ID', 'HW_SEVERITY', 'HW_TEST_MODE', 'HW_LOG_LEVEL']
-const sortedEnvVars = computed(() => {
- if (!selectedSensor.value?.deployment?.env_vars) return []
- return [...selectedSensor.value.deployment.env_vars]
- .filter(env => !env.hidden)
- .sort((a, b) => {
- const aIsCore = coreVars.includes(a.name); const bIsCore = coreVars.includes(b.name)
- if (aIsCore && !bIsCore) return -1
- if (!aIsCore && bIsCore) return 1
- if (aIsCore && bIsCore) return coreVars.indexOf(a.name) - coreVars.indexOf(b.name)
- return a.name.localeCompare(b.name)
- })
-})
-
-const mapNodeSensor = (sensor) => ({
- id: sensor.sensor_id || sensor.id || sensor.name,
- name: sensor.custom_name || sensor.name || sensor.sensor_id || sensor.id,
- display: sensor.custom_name || sensor.name || sensor.sensor_id || sensor.id,
- status: sensor.status || 'down',
- events24h: sensor.events_24h || sensor.events24h || 0,
- isSilenced: sensor.is_silenced || sensor.isSilenced || false,
- osi: sensor.osi_layer || sensor.osi || 'Sensor',
- icon: sensor.icon || sensor.icon_svg || 'M12 12h0',
- ...sensor,
-})
-
-const fetchNodeDetails = async () => {
- if (!selectedNodeId.value) {
- appStore.currentView = 'fleet'
- return
- }
-
- isLoading.value = true
- fetchError.value = false
-
- try {
- const response = await fetch(`/api/v1/nodes/${encodeURIComponent(selectedNodeId.value)}`, { cache: 'no-store' })
- if (!response.ok) throw new Error(`Failed to fetch node: ${response.status}`)
- const data = await response.json()
- node.value = {
- id: data.id || data.node_id || selectedNodeId.value,
- alias: data.alias || data.name || 'Unknown Node',
- status: data.status || 'unknown',
- publicIp: data.public_ip || data.publicIp || null,
- privateIp: data.private_ip || data.privateIp || null,
- apiKey: data.apiKey || null,
- tags: data.tags || [],
- hasPendingConfig: data.has_pending_config || data.hasPendingConfig || false,
- lastEvent: data.last_event || data.lastEvent || 'Unknown',
- installedSensors: Array.isArray(data.installed_sensors || data.installedSensors)
- ? (data.installed_sensors || data.installedSensors).map(mapNodeSensor)
- : [],
- }
- } catch (error) {
- console.error('Failed to load node details', error)
- fetchError.value = true
- appStore.currentView = 'fleet'
- } finally {
- isLoading.value = false
- }
-}
-
-const handleToggleSensorSilence = async (sensor) => {
- if (!node.value.id || !sensor.id) return
-
- const previous = sensor.isSilenced
- sensor.isSilenced = !sensor.isSilenced
-
- try {
- const response = await fetch(`/api/v1/nodes/${encodeURIComponent(node.value.id)}/sensors/${encodeURIComponent(sensor.id)}/silence`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ is_silenced: sensor.isSilenced }),
- })
- if (!response.ok) throw new Error(`Server error: ${response.status}`)
- } catch (err) {
- sensor.isSilenced = previous
- console.error('Failed to toggle silence:', err)
- alert('Unable to change sensor silence state. Please try again.')
- }
-}
-
-const handleRemoveSensor = async (sensor) => {
- if (!node.value.id || !sensor.id) return
- if (!confirm(`Delete sensor ${sensor.name || sensor.id} from ${node.value.alias}?`)) return
-
- try {
- const response = await fetch(`/api/v1/nodes/${encodeURIComponent(node.value.id)}/sensors/${encodeURIComponent(sensor.id)}`, {
- method: 'DELETE',
- })
- if (!response.ok) throw new Error(`Server error: ${response.status}`)
- node.value.installedSensors = node.value.installedSensors.filter(s => s.id !== sensor.id)
- } catch (err) {
- console.error('Failed to remove sensor:', err)
- alert('Could not remove sensor. Please try again.')
- }
-}
-
-const handleAddSensorToNode = async () => {
- if (!selectedSensor.value || !node.value.id) return
-
- const safeEnvValues = Object.fromEntries(Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : '']))
- const payload = {
- sensor_id: selectedSensor.value.id || selectedSensor.value.sensor_id || selectedSensor.value.name,
- custom_name: selectedSensor.value.name || selectedSensor.value.id,
- config_values: safeEnvValues,
- }
-
- try {
- const response = await fetch(`/api/v1/nodes/${encodeURIComponent(node.value.id)}/sensors`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- })
- if (!response.ok) throw new Error(`Server error: ${response.status}`)
- await fetchNodeDetails()
- closeSensor()
- } catch (err) {
- console.error('Failed to add sensor to node:', err)
- alert('Could not add sensor to this node. Please try again.')
- }
-}
-
-const handleManageKey = () => {
- showKeyModal.value = true
-}
-
-const getNodeApiKey = () => node.value.apiKey || null
-
-const copyNodeKeyToClipboard = async () => {
- const apiKey = getNodeApiKey()
- if (!apiKey) return
- try {
- await navigator.clipboard.writeText(apiKey)
- alert('Node API Key copied to clipboard')
- } catch (err) {
- console.error('Failed to copy node API Key:', err)
- alert('Unable to copy Node API Key. Please copy it manually.')
- }
-}
-
-const copySyncYamlToClipboard = async () => {
- if (!syncComposeYaml.value) return
- try {
- await navigator.clipboard.writeText(syncComposeYaml.value)
- alert('Compose YAML copied to clipboard')
- } catch (err) {
- console.error('Failed to copy compose YAML:', err)
- alert('Unable to copy compose YAML. Please copy it manually.')
- }
-}
-
-const triggerManualSync = async () => {
- const apiKey = getNodeApiKey()
- if (!apiKey) {
- alert('Unable to sync this node: missing node API key.')
- return
- }
-
- try {
- const response = await fetch('/api/v1/nodes/compose', {
- headers: { Authorization: `Bearer ${apiKey}` },
- })
-
- if (!response.ok) {
- throw new Error(`Node sync failed: ${response.status}`)
- }
-
- syncComposeYaml.value = await response.text()
- showSyncModal.value = true
- await fetchNodeDetails()
- } catch (err) {
- console.error('Failed to sync node:', err)
- alert('Unable to sync this node. Please try again.')
- }
-}
-
-onMounted(() => {
- fetchNodeDetails()
- eventsStore.fetchEvents(false, selectedNodeId.value)
-})
-
-watch(selectedNodeId, (value) => {
- if (!value) {
- appStore.currentView = 'fleet'
- return
- }
- fetchNodeDetails()
- eventsStore.fetchEvents(false, value)
-})
-
-const timeAgo = (dateStr) => {
- if (!dateStr) return 'Unknown'
- const diff = Math.floor((new Date() - new Date(dateStr)) / 1000)
- if (diff < 60) return `${diff}s ago`
- if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
- if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
- return `${Math.floor(diff / 86400)}d ago`
-}
-
-const recentActivity = computed(() => {
- return eventsStore.filteredEvents.slice(0, 10).map(e => ({
- id: e.id,
- time: timeAgo(e.timestamp),
- severity: e.severity || 'info',
- event_trigger: e.event_trigger || 'Alert',
- source: e.source || 'Unknown'
- }))
-})
-
-// --- EDIT MODAL STATE ---
const showEditModal = ref(false)
const editNodeForm = ref({ id: '', alias: '', publicIp: '', privateIp: '' })
const tagInput = ref('')
const tagsList = ref([])
+// --- SEVERITY CONFIG ---
+const severityOptions = [
+ { value: 'info', label: 'Info', textClass: 'text-info', hoverClass: 'hover:bg-info/10 hover:text-info' },
+ { value: 'low', label: 'Low', textClass: 'text-low', hoverClass: 'hover:bg-low/10 hover:text-low' },
+ { value: 'medium', label: 'Medium', textClass: 'text-medium', hoverClass: 'hover:bg-medium/10 hover:text-medium' },
+ { value: 'high', label: 'High', textClass: 'text-high', hoverClass: 'hover:bg-high/10 hover:text-high' },
+ { value: 'critical', label: 'Critical', textClass: 'text-critical', hoverClass: 'hover:bg-critical/10 hover:text-critical' }
+]
+
+const currentSeverity = computed(() =>
+ severityOptions.find(s => s.value === envVarValues.value['HW_SEVERITY']) || severityOptions[3]
+)
+
+const toggleSeverity = () => {
+ isSeverityOpen.value = !isSeverityOpen.value
+ activeEnvVar.value = isSeverityOpen.value ? 'HW_SEVERITY' : null
+}
+const closeSeverity = () => { isSeverityOpen.value = false; activeEnvVar.value = null }
+const selectSeverity = (val) => { envVarValues.value['HW_SEVERITY'] = val; closeSeverity() }
+
+// --- ENV VAR HELPERS ---
+const getUIDefault = (def) => {
+ if (!def) return ''
+ if (!def.includes('{{')) return def
+ const elseMatch = def.match(/\{\{\s*else\s*\}\}(.*?)\{\{\s*end\s*\}\}/)
+ if (elseMatch) return elseMatch[1].trim()
+ const funcMatch = def.match(/\{\{\s*[a-zA-Z]+\s+([0-9]+)\s*\}\}/)
+ if (funcMatch) return funcMatch[1].trim()
+ return ''
+}
+
+const coreVars = ['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SENSOR_ID', 'HW_SEVERITY', 'HW_TEST_MODE', 'HW_LOG_LEVEL']
+
+const sortedEnvVars = computed(() => {
+ if (!selectedSensor.value?.deployment?.env_vars) return []
+ return [...selectedSensor.value.deployment.env_vars]
+ .filter(env => !env.hidden)
+ .sort((a, b) => {
+ const aIsCore = coreVars.includes(a.name)
+ const bIsCore = coreVars.includes(b.name)
+ if (aIsCore && !bIsCore) return -1
+ if (!aIsCore && bIsCore) return 1
+ if (aIsCore && bIsCore) return coreVars.indexOf(a.name) - coreVars.indexOf(b.name)
+ return a.name.localeCompare(b.name)
+ })
+})
+
+// --- SENSOR ACTIONS (delegated to store) ---
+
+const handleToggleSensorSilence = async (sensor) => {
+ if (!node.value?.id || !sensor.id) return
+ try {
+ await fleetStore.toggleSilence(node.value.id, sensor.id, !sensor.isSilenced)
+ } catch (err) {
+ alert('Unable to change sensor silence state. Please try again.')
+ }
+}
+
+const handleRemoveSensor = async (sensor) => {
+ if (!node.value?.id || !sensor.id) return
+ try {
+ await fleetStore.removeSensor(node.value.id, sensor.id)
+ } catch (err) {
+ alert('Could not remove sensor. Please try again.')
+ }
+}
+
+const handleAddSensorToNode = async () => {
+ if (!selectedSensor.value || !node.value?.id) return
+
+ const safeEnvValues = Object.fromEntries(
+ Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : ''])
+ )
+
+ try {
+ await fleetStore.addSensor(node.value.id, {
+ sensorId: selectedSensor.value.id || selectedSensor.value.sensor_id || selectedSensor.value.name,
+ customName: selectedSensor.value.name || selectedSensor.value.id,
+ configValues: safeEnvValues,
+ })
+ closeSensor()
+ } catch (err) {
+ alert('Could not add sensor to this node. Please try again.')
+ }
+}
+
+// --- NODE ACTIONS (delegated to store) ---
+
+const handleManageKey = () => { showKeyModal.value = true }
+
+const copyNodeKeyToClipboard = async () => {
+ const apiKey = node.value?.apiKey
+ if (!apiKey) return
+ try {
+ await navigator.clipboard.writeText(apiKey)
+ alert('Node API Key copied to clipboard')
+ } catch (err) {
+ alert('Unable to copy Node API Key. Please copy it manually.')
+ }
+}
+
+const copySyncYamlToClipboard = async () => {
+ if (!syncComposeYaml.value) return
+ try {
+ await navigator.clipboard.writeText(syncComposeYaml.value)
+ alert('Compose YAML copied to clipboard')
+ } catch (err) {
+ alert('Unable to copy compose YAML. Please copy it manually.')
+ }
+}
+
+const triggerManualSync = async () => {
+ if (!node.value?.id) return
+ try {
+ syncComposeYaml.value = await fleetStore.syncNode(node.value.id)
+ showSyncModal.value = true
+ } catch (err) {
+ alert('Unable to sync this node. Please try again.')
+ }
+}
+
+// --- NAVIGATION ---
+
+watch(selectedNodeId, async (value) => {
+ if (!value) {
+ appStore.currentView = 'fleet'
+ return
+ }
+ await fleetStore.fetchNodeDetails(value)
+ eventsStore.fetchEvents(false, value)
+}, { immediate: true })
+
+const timeAgo = (dateStr) => {
+ if (!dateStr) return 'Unknown'
+ const diff = Math.floor((new Date() - new Date(dateStr)) / 1000)
+ if (diff < 60) return `${diff}s ago`
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
+ return `${Math.floor(diff / 86400)}d ago`
+}
+
+const recentActivity = computed(() => {
+ return eventsStore.filteredEvents.slice(0, 10).map(e => ({
+ id: e.id,
+ time: timeAgo(e.timestamp),
+ severity: e.severity || 'info',
+ event_trigger: e.event_trigger || 'Alert',
+ source: e.source || 'Unknown'
+ }))
+})
+
+// --- EDIT MODAL ---
+
const openEditModal = () => {
- editNodeForm.value = {
- id: node.value.id,
- alias: node.value.alias,
- publicIp: node.value.publicIp || '',
- privateIp: node.value.privateIp || ''
- }
- tagsList.value = [...(node.value.tags || [])]
- showEditModal.value = true
+ if (!node.value) return
+ editNodeForm.value = {
+ id: node.value.id,
+ alias: node.value.alias,
+ publicIp: node.value.publicIp || '',
+ privateIp: node.value.privateIp || ''
+ }
+ tagsList.value = [...(node.value.tags || [])]
+ showEditModal.value = true
}
const addTag = () => {
- const val = tagInput.value.trim()
- if (val && !tagsList.value.includes(val)) {
- tagsList.value.push(val)
- }
- tagInput.value = ''
+ const val = tagInput.value.trim()
+ if (val && !tagsList.value.includes(val)) tagsList.value.push(val)
+ tagInput.value = ''
}
-const removeTag = (index) => {
- tagsList.value.splice(index, 1)
-}
+const removeTag = (index) => { tagsList.value.splice(index, 1) }
const handleEditSubmit = async () => {
- try {
- const response = await fetch(`/api/v1/nodes/${editNodeForm.value.id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- alias: editNodeForm.value.alias,
- tags: tagsList.value,
- publicIp: editNodeForm.value.publicIp,
- privateIp: editNodeForm.value.privateIp
- }),
- })
-
- if (!response.ok) throw new Error('Failed to update node')
-
- showEditModal.value = false
- await fetchNodeDetails()
- await fleetStore.fetchFleet()
- } catch (err) {
- console.error('Update node failed:', err)
- }
+ try {
+ await fleetStore.updateNode(editNodeForm.value.id, {
+ alias: editNodeForm.value.alias,
+ tags: tagsList.value,
+ publicIp: editNodeForm.value.publicIp,
+ privateIp: editNodeForm.value.privateIp,
+ })
+ showEditModal.value = false
+ } catch (err) {
+ alert('Failed to update node. Please try again.')
+ }
}
-watch(envVarValues, () => { fetchYamlFromHub(); }, { deep: true });
-watch(activeEnvVar, () => { applyHighlighting(); });
+// --- SENSOR CATALOG ---
+
+watch(envVarValues, () => { fetchYamlFromHub() }, { deep: true })
+watch(activeEnvVar, () => { applyHighlighting() })
onMounted(async () => {
- try {
- isLoading.value = true;
- const response = await fetch("/api/v1/manifests", { cache: "no-store" });
- if (!response.ok) throw new Error(`HTTP error`);
- sensors.value = await response.json();
- } catch (error) {
- console.error(error); fetchError.value = true;
- } finally {
- isLoading.value = false;
- }
-});
+ isManifestLoading.value = true
+ try {
+ sensors.value = await fleetStore.fetchManifests()
+ } catch (error) {
+ console.error(error)
+ fetchError.value = true
+ } finally {
+ isManifestLoading.value = false
+ }
+})
const openSensor = (sensor) => {
- const apiKey = getNodeApiKey()
- selectedSensor.value = sensor; activeTab.value = 'readme'; envVarValues.value = {};
- envVarValues.value['HW_SEVERITY'] = 'critical';
- envVarValues.value['HW_HUB_ENDPOINT'] = config.hubEndpoint || window.location.origin;
- envVarValues.value['HW_HUB_KEY'] = apiKey || '';
- sensor.deployment?.env_vars?.forEach(env => {
- if (!['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SEVERITY'].includes(env.name)) {
- envVarValues.value[env.name] = getUIDefault(env.default);
- }
- })
- document.body.style.overflow = 'hidden';
- fetchYamlFromHub();
+ const apiKey = node.value?.apiKey
+ selectedSensor.value = sensor
+ activeTab.value = 'readme'
+ envVarValues.value = {}
+ envVarValues.value['HW_SEVERITY'] = 'critical'
+ envVarValues.value['HW_HUB_ENDPOINT'] = config.hubEndpoint || window.location.origin
+ envVarValues.value['HW_HUB_KEY'] = apiKey || ''
+ sensor.deployment?.env_vars?.forEach(env => {
+ if (!['HW_HUB_ENDPOINT', 'HW_HUB_KEY', 'HW_SEVERITY'].includes(env.name)) {
+ envVarValues.value[env.name] = getUIDefault(env.default)
+ }
+ })
+ document.body.style.overflow = 'hidden'
+ fetchYamlFromHub()
}
-const closeSensor = () => { selectedSensor.value = null; envVarValues.value = {}; activeEnvVar.value = null; document.body.style.overflow = ''; }
+const closeSensor = () => {
+ selectedSensor.value = null
+ envVarValues.value = {}
+ activeEnvVar.value = null
+ document.body.style.overflow = ''
+}
const fetchYamlFromHub = async () => {
- if (!selectedSensor.value) return;
- const apiKey = getNodeApiKey()
- const safeEnvValues = Object.fromEntries(Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : '']));
- const payload = {
- node_id: node.value.id,
- hub_endpoint: config.hubEndpoint || window.location.origin,
- hub_key: apiKey || '',
- sensors: [{ sensor_id: selectedSensor.value.id, env_values: safeEnvValues, manifest: selectedSensor.value }]
- };
- try {
- const response = await fetch('/api/v1/compose/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
- if (!response.ok) throw new Error("Failed to compile YAML");
- rawCompose.value = await response.text();
- applyHighlighting();
- } catch (e) {
- console.error(e); rawCompose.value = "services:\n error:\n error_generating_yaml"; highlightedCompose.value = rawCompose.value;
- }
+ if (!selectedSensor.value || !node.value?.id) return
+
+ const apiKey = node.value.apiKey
+ const safeEnvValues = Object.fromEntries(
+ Object.entries(envVarValues.value).map(([k, v]) => [k, v !== undefined && v !== null ? String(v) : ''])
+ )
+
+ try {
+ rawCompose.value = await fleetStore.generateCompose({
+ node_id: node.value.id,
+ hub_endpoint: config.hubEndpoint || window.location.origin,
+ hub_key: apiKey || '',
+ sensors: [{
+ sensor_id: selectedSensor.value.id,
+ env_values: safeEnvValues,
+ manifest: selectedSensor.value
+ }]
+ })
+ applyHighlighting()
+ } catch (e) {
+ rawCompose.value = 'services:\n error:\n error_generating_yaml'
+ highlightedCompose.value = rawCompose.value
+ }
}
const applyHighlighting = () => {
- let htmlYaml = rawCompose.value;
- if (activeEnvVar.value) {
- const escapedName = activeEnvVar.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const regex = new RegExp(`^.*\\b${escapedName}\\b.*$`, 'gm');
- htmlYaml = htmlYaml.replace(regex, `$&`);
+ let htmlYaml = rawCompose.value
+ if (activeEnvVar.value) {
+ const escapedName = activeEnvVar.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const regex = new RegExp(`^.*\\b${escapedName}\\b.*$`, 'gm')
+ htmlYaml = htmlYaml.replace(regex, `$&`)
+ }
+ highlightedCompose.value = htmlYaml
+ nextTick(() => {
+ if (composePre.value) {
+ const highlightEl = composePre.value.querySelector('.active-highlight')
+ if (highlightEl) {
+ const scrollPos = highlightEl.offsetTop - (composePre.value.clientHeight / 2) + (highlightEl.clientHeight / 2)
+ composePre.value.scrollTo({ top: Math.max(0, scrollPos), behavior: 'smooth' })
+ }
}
- highlightedCompose.value = htmlYaml;
- nextTick(() => {
- if (composePre.value) {
- const highlightEl = composePre.value.querySelector('.active-highlight');
- if (highlightEl) {
- const scrollPos = highlightEl.offsetTop - (composePre.value.clientHeight / 2) + (highlightEl.clientHeight / 2);
- composePre.value.scrollTo({ top: Math.max(0, scrollPos), behavior: 'smooth' });
- }
- }
- });
+ })
}
@@ -424,7 +339,7 @@ const applyHighlighting = () => {
-
+
@@ -582,7 +497,7 @@ const applyHighlighting = () => {
Sensor Catalog
-
+
diff --git a/Hub/ui/src/views/Settings.vue b/Hub/ui/src/views/Settings.vue
index 78d9932..6e2204b 100644
--- a/Hub/ui/src/views/Settings.vue
+++ b/Hub/ui/src/views/Settings.vue
@@ -1,5 +1,6 @@
diff --git a/Hub/ui/src/views/Setup.vue b/Hub/ui/src/views/Setup.vue
index 0322531..22ad99f 100644
--- a/Hub/ui/src/views/Setup.vue
+++ b/Hub/ui/src/views/Setup.vue
@@ -1,5 +1,6 @@