implemented frontend routing instead of v-if statements, updated backend router to solve SPA problem, updated docs to reflect changes, implemented summary endpoint for node details events summary

This commit is contained in:
AndReicscs
2026-06-04 13:27:31 +00:00
parent 66f4f1ed82
commit b9b7670efd
21 changed files with 307 additions and 66 deletions
+3 -3
View File
@@ -55,9 +55,9 @@ HoneyWire uses CQRS-style projections for analytics to avoid complex front-end d
2. Break down complex parts into reusable widgets in `ui/src/components/`.
**Step 2: Register the View**
*Note: HoneyWire currently does not use a dedicated frontend router. Views are conditionally rendered.*
1. Open the main layout orchestrator (e.g., `ui/src/App.vue` or the main shell component).
2. Add your new view to the `v-if` / `v-else-if` conditional rendering logic linked to the active UI state (like `currentView`).
1. Open the Vue Router configuration file at `ui/src/index.ts`.
2. Add your new view to the `routes` array using a dynamic import (e.g., `{ path: '/new-feature', name: 'new-feature', component: () => import('./views/NewFeature.vue') }`).
3. Update navigation components (like `ui/src/components/layout/Sidebar.vue`) to use `router.push('/new-feature')` to navigate to your new page.
**Step 3: Wire State Management**
1. If the page requires complex state, create a new file in `ui/src/stores/` using Pinia.
+4
View File
@@ -41,6 +41,10 @@ The entry point for all network requests.
* Extracts credentials (Cookies, Bearer tokens), validates them via the `auth.Service`, and attaches the resulting `nodeID` to the `http.Request` context.
* **Router (`router.go`):**
* Maps HTTP routes to specific Handlers and applies Middleware groups.
* **SPA Routing & Frontend Embedding:** The router serves the compiled Vue 3 frontend using `go:embed`. It implements a secure Single Page Application fallback:
1. **Strict API Protection:** Requests prefixed with `/api/` return strict 404s if unmatched, preventing HTML from leaking into API clients.
2. **Static Assets:** Existing static files (e.g., `.css`, `.js`) are served normally from the embedded filesystem.
3. **SPA Fallback:** Any unrecognized non-API route transparently falls back to `index.html`, allowing the Vue Router to manage history mode URLs (like `/dashboard`) without server-side 404 errors.
### 2. Domain Service Layer (`internal/services`)
The brain of the application. Everything in `internal/services/*` is framework-agnostic.
+29
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/honeywire/hub/internal/projections/severity"
"github.com/honeywire/hub/internal/projections/summary"
"github.com/honeywire/hub/internal/projections/uptime"
"github.com/honeywire/hub/internal/projections/velocity"
"github.com/honeywire/hub/internal/store"
@@ -42,6 +43,34 @@ func (h *AnalyticsHandler) GetVelocityAnalytics(w http.ResponseWriter, r *http.R
SendJSON(w, http.StatusOK, projection)
}
func (h *AnalyticsHandler) GetSummaryAnalytics(w http.ResponseWriter, r *http.Request) {
timeframe := r.URL.Query().Get("timeframe")
if timeframe == "" {
timeframe = "24H"
}
nodeID := r.URL.Query().Get("nodeId")
sensorID := r.URL.Query().Get("sensorId")
viewingArchiveStr := r.URL.Query().Get("archived")
viewingArchive := 0
if viewingArchiveStr == "true" || viewingArchiveStr == "1" {
viewingArchive = 1
}
projector := summary.NewProjector(h.Store)
projection, err := projector.BuildSummaryProjection(r.Context(), timeframe, nodeID, sensorID, viewingArchive)
if err != nil {
if err.Error() == "not_found" {
RespondError(w, "Node not found", http.StatusNotFound)
return
}
RespondError(w, err.Error(), http.StatusInternalServerError)
return
}
SendJSON(w, http.StatusOK, projection)
}
func (h *AnalyticsHandler) GetSeverityAnalytics(w http.ResponseWriter, r *http.Request) {
timeframe := r.URL.Query().Get("timeframe")
if timeframe == "" {
+21 -1
View File
@@ -5,6 +5,7 @@ import (
"io/fs"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -86,6 +87,7 @@ func SetupRouter(cfg RouterConfig) (*chi.Mux, error) {
// Telemetry & State (For UI Dashboards)
r.Get("/api/v1/events/severity", cfg.Analytics.GetSeverityAnalytics)
r.Get("/api/v1/events/velocity", cfg.Analytics.GetVelocityAnalytics)
r.Get("/api/v1/events/summary", cfg.Analytics.GetSummaryAnalytics)
r.Get("/api/v1/events", cfg.Events.GetEvents)
r.Get("/api/v1/uptime", cfg.Analytics.GetUptime)
r.Get("/api/v1/system/state", cfg.Config.GetSystemState)
@@ -119,7 +121,25 @@ func SetupRouter(cfg RouterConfig) (*chi.Mux, error) {
}
fileServer := http.FileServer(http.FS(distFS))
r.Handle("/*", fileServer)
r.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Strict API Protection: Never return HTML for missing API routes
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// 2. Check if the static file exists in the embedded filesystem
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "."
}
if _, err := fs.Stat(distFS, path); err != nil {
// 3. SPA Fallback: Serve index.html for frontend routes
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
}))
return r, nil
}
View File
@@ -0,0 +1,28 @@
package summary
import (
"time"
"github.com/honeywire/hub/internal/models"
)
// CalculateSummary iterates over raw events and aggregates the summary projection
func CalculateSummary(events []models.Event, timeframe string, since time.Time) *SummaryDTO {
dto := &SummaryDTO{
Timeframe: timeframe,
BySensor: make(map[string]int),
}
for _, e := range events {
if !since.IsZero() {
t, err := time.Parse(time.RFC3339, e.Timestamp)
if err == nil && t.Before(since) {
continue
}
}
dto.TotalEvents++
dto.BySensor[e.SensorID]++
}
return dto
}
+8
View File
@@ -0,0 +1,8 @@
package summary
// SummaryDTO is the flat read-model for event counts
type SummaryDTO struct {
Timeframe string `json:"timeframe"`
TotalEvents int `json:"totalEvents"`
BySensor map[string]int `json:"bySensor"`
}
@@ -0,0 +1,48 @@
package summary
import (
"context"
"time"
"github.com/honeywire/hub/internal/models"
)
type ProjectionStore interface {
GetEvents(isArchived int, nodeID, sensorID string) ([]models.Event, error)
}
type Projector struct {
Store ProjectionStore
}
func NewProjector(s ProjectionStore) *Projector {
return &Projector{Store: s}
}
func (p *Projector) BuildSummaryProjection(ctx context.Context, timeframe, nodeID, sensorID string, viewingArchive int) (*SummaryDTO, error) {
now := time.Now().UTC()
var since time.Time
switch timeframe {
case "1H":
since = now.Add(-1 * time.Hour)
case "7D":
since = now.Add(-7 * 24 * time.Hour)
case "30D":
since = now.Add(-30 * 24 * time.Hour)
case "alltime":
since = time.Time{}
case "24H":
fallthrough
default:
since = now.Add(-24 * time.Hour)
timeframe = "24H"
}
events, err := p.Store.GetEvents(viewingArchive, nodeID, sensorID)
if err != nil {
return nil, err
}
return CalculateSummary(events, timeframe, since), nil
}
+23 -1
View File
@@ -12,7 +12,8 @@
"escape-html": "^1.0.3",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.4",
"vue": "^3.5.32"
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.4",
@@ -1982,6 +1983,27 @@
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-tsc": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.3.tgz",
+2 -1
View File
@@ -13,7 +13,8 @@
"escape-html": "^1.0.3",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.4",
"vue": "^3.5.32"
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.4",
+9 -12
View File
@@ -1,15 +1,12 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import Sidebar from './components/layout/Sidebar.vue'
import Header from './components/layout/Header.vue'
import Dashboard from './views/Dashboard.vue'
import FleetView from './views/FleetManagement.vue'
import NodeDetailView from './views/NodeDetails.vue'
import Login from './views/Login.vue'
import Settings from './views/Settings.vue'
import Setup from './views/Setup.vue'
import Login from './views/Login.vue'
import { useConfigStore } from './stores/Config/config'
import { useAppStore } from './stores/System/app'
@@ -22,7 +19,10 @@ const appStore = useAppStore()
const fleetStore = useFleetStore()
const eventsStore = useEventsStore()
const { isInitialized, requiresSetup, isAuthenticated, currentView, viewingArchive, bootstrapError } = storeToRefs(appStore)
const router = useRouter()
const route = useRoute()
const { isInitialized, requiresSetup, isAuthenticated, viewingArchive, bootstrapError } = storeToRefs(appStore)
watch([viewingArchive, () => fleetStore.selectedNode?.id, () => fleetStore.selectedSensor?.sensorId],
([isArchived, nodeId, sensorId]) => {
@@ -51,8 +51,8 @@ const loadAppData = async () => {
// TODO: REMOVE DEBUG OVERRIDE BEFORE PRODUCTION
// You can test the "First Startup" UI state at any time by running:
// localStorage.setItem('DEBUG_FIRST_STARTUP', 'true') in your browser console.
if ((fleetStore.nodes.length === 0 || localStorage.getItem('DEBUG_FIRST_STARTUP') === 'true') && appStore.currentView !== 'fleet') {
appStore.setView('fleet')
if ((fleetStore.nodes.length === 0 || localStorage.getItem('DEBUG_FIRST_STARTUP') === 'true') && route.name !== 'fleet') {
router.push('/fleet')
}
wsService.on('onNewEvent', (payload: any) => {
@@ -163,10 +163,7 @@ onUnmounted(() => {
<main class="flex-1 flex flex-col min-w-0 bg-grid">
<Header />
<div class="flex-1 overflow-auto custom-scroll p-4 sm:p-6">
<Dashboard v-if="currentView === 'dashboard'" />
<FleetView v-else-if="currentView === 'fleet'" />
<NodeDetailView v-else-if="currentView === 'node-detail'" />
<Settings v-else-if="currentView === 'settings'" />
<router-view />
</div>
</main>
</div>
+4 -2
View File
@@ -1,12 +1,14 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import { useAppStore } from '../../stores/System/app'
import { useEventsStore } from '../../stores/Events/events'
const appStore = useAppStore()
const eventsStore = useEventsStore()
const route = useRoute()
const { currentView, isArmed } = storeToRefs(appStore)
const { isArmed } = storeToRefs(appStore)
const { unreadCount } = storeToRefs(eventsStore)
const handleMarkAllRead = async () => {
@@ -38,7 +40,7 @@ const handleMarkAllRead = async () => {
<span class="text-h1 font-medium text-text-h leading-none tracking-wide">HoneyWire</span>
</div>
<h2 class="text-base text-text-m font-medium capitalize hidden sm:block">{{ currentView?.replace('-', ' ') }}</h2>
<h2 class="text-base text-text-m font-medium capitalize hidden sm:block">{{ String(route.name || '')?.replace('-', ' ') }}</h2>
</div>
<div class="flex items-center gap-3">
+11 -8
View File
@@ -1,5 +1,6 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '../../stores/System/app'
import { useEventsStore } from '../../stores/Events/events'
import { useFleetStore } from '../../stores/Fleet/fleet'
@@ -8,7 +9,10 @@ const appStore = useAppStore()
const eventsStore = useEventsStore()
const fleetStore = useFleetStore()
const { currentView, sidebarOpen, viewingArchive, version } = storeToRefs(appStore)
const route = useRoute()
const router = useRouter()
const { sidebarOpen, viewingArchive, version } = storeToRefs(appStore)
const clearLogs = async () => {
try {
@@ -32,7 +36,6 @@ const clearLogs = async () => {
if (!response.ok) throw new Error(`Server error: ${response.status}`)
eventsStore.purgeEvents()
fleetStore.fetchFleet() // Refresh node & sensor stats (e.g., Event Volume 24h)
alert("Database purged successfully.")
}
@@ -44,7 +47,7 @@ const clearLogs = async () => {
const goToDashboard = () => {
fleetStore.clearSelection()
appStore.setView('dashboard')
router.push('/dashboard')
}
</script>
@@ -67,7 +70,7 @@ const goToDashboard = () => {
<button @click="goToDashboard"
type="button"
class="w-full flex items-center px-3 py-2.5 rounded-md text-base text-text-h transition-all border outline-none"
:class="currentView === 'dashboard' ? 'bg-secondary-selected shadow-sm border-secondary-border' : 'border-transparent text-secondary-text hover:bg-secondary-hover hover:text-text-h'"
:class="route.name === 'dashboard' ? 'bg-secondary-selected shadow-sm border-secondary-border' : 'border-transparent text-secondary-text hover:bg-secondary-hover hover:text-text-h'"
:title="!sidebarOpen ? 'Dashboard' : ''">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
<div class="overflow-hidden transition-all duration-fast ease-in-out whitespace-nowrap flex items-center"
@@ -76,10 +79,10 @@ const goToDashboard = () => {
</div>
</button>
<button @click="appStore.setView('fleet')"
<button @click="router.push('/fleet')"
type="button"
class="w-full flex items-center px-3 py-2.5 rounded-md text-base text-text-h transition-all border outline-none"
:class="currentView === 'fleet' ? 'bg-secondary-selected shadow-sm border-secondary-border' : 'border-transparent text-secondary-text hover:bg-secondary-hover hover:text-text-h'"
:class="route.name === 'fleet' || route.name === 'node-detail' ? 'bg-secondary-selected shadow-sm border-secondary-border' : 'border-transparent text-secondary-text hover:bg-secondary-hover hover:text-text-h'"
:title="!sidebarOpen ? 'Fleet Management' : ''">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path></svg>
<div class="overflow-hidden transition-all duration-fast ease-in-out whitespace-nowrap flex items-center"
@@ -88,10 +91,10 @@ const goToDashboard = () => {
</div>
</button>
<button @click="appStore.setView('settings')"
<button @click="router.push('/settings')"
type="button"
class="w-full flex items-center px-3 py-2.5 rounded-md text-base text-text-h transition-all border outline-none"
:class="currentView === 'settings' ? 'bg-secondary-selected shadow-sm border-secondary-border' : 'border-transparent text-secondary-text hover:bg-secondary-hover hover:text-text-h'"
:class="route.name === 'settings' ? 'bg-secondary-selected shadow-sm border-secondary-border' : 'border-transparent text-secondary-text hover:bg-secondary-hover hover:text-text-h'"
:title="!sidebarOpen ? 'Settings' : ''">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<div class="overflow-hidden transition-all duration-fast ease-in-out whitespace-nowrap flex items-center"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { formatSensorId } from '../../utils/formatSensorId'
import { useEventsStore } from '../../stores/Events/events'
const props = defineProps<{
node: any,
@@ -11,14 +12,20 @@ const emit = defineEmits<{
(e: 'viewAllEvents'): void
}>()
const eventsStore = useEventsStore()
const maxSensorEvents = computed(() => {
const sensors = props.node?.installedSensors || []
if (sensors.length === 0) return 1
return Math.max(...sensors.map((s: any) => s.events24h || 0), 1)
const summary = eventsStore.summaryProjection
if (!summary || !summary.bySensor) return 1
const values = Object.values(summary.bySensor)
if (values.length === 0) return 1
return Math.max(...values, 1)
})
const topSensors = computed(() => {
const summary = eventsStore.summaryProjection
return [...(props.node?.installedSensors || [])]
.map((s: any) => ({ ...s, events24h: summary?.bySensor?.[s.sensorId] || 0 }))
.sort((a: any, b: any) => (b.events24h || 0) - (a.events24h || 0))
})
+35
View File
@@ -0,0 +1,35 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('./views/Dashboard.vue')
},
{
path: '/fleet',
name: 'fleet',
component: () => import('./views/FleetManagement.vue')
},
{
path: '/fleet/node/:id',
name: 'node-detail',
component: () => import('./views/NodeDetails.vue')
},
{
path: '/settings',
name: 'settings',
component: () => import('./views/Settings.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
+2
View File
@@ -2,7 +2,9 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './assets/style.css'
import App from './App.vue'
import router from './index'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+42
View File
@@ -41,6 +41,12 @@ export interface ThreatVelocityProjection {
recentEventCount: number
}
export interface SummaryProjection {
timeframe: string
totalEvents: number
bySensor: Record<string, number>
}
export interface EventsState {
events: EventPayload[]
unreadCount: number
@@ -48,12 +54,15 @@ export interface EventsState {
isFetching: boolean
severityProjection: SeverityProjection | null
threatVelocityProjection: ThreatVelocityProjection | null
summaryProjection: SummaryProjection | null
isFetchingThreatVelocityProjection: boolean
isFetchingSummaryProjection: boolean
lastVelocityInvalidation: number | null
}
let severityAbortController: AbortController | null = null
let velocityAbortController: AbortController | null = null
let summaryAbortController: AbortController | null = null
// --- NORMALIZATION ---
const normalizeEvent = (e: any): EventPayload => {
@@ -75,7 +84,9 @@ export const useEventsStore = defineStore('events', () => {
isFetching: false,
severityProjection: null,
threatVelocityProjection: null,
summaryProjection: null,
isFetchingThreatVelocityProjection: false,
isFetchingSummaryProjection: false,
lastVelocityInvalidation: null
})
@@ -91,7 +102,9 @@ export const useEventsStore = defineStore('events', () => {
const isFetching = computed<boolean>(() => state.value.isFetching)
const severityProjection = computed<SeverityProjection | null>(() => state.value.severityProjection)
const threatVelocityProjection = computed<ThreatVelocityProjection | null>(() => state.value.threatVelocityProjection)
const summaryProjection = computed<SummaryProjection | null>(() => state.value.summaryProjection)
const isFetchingThreatVelocityProjection = computed<boolean>(() => state.value.isFetchingThreatVelocityProjection)
const isFetchingSummaryProjection = computed<boolean>(() => state.value.isFetchingSummaryProjection)
const lastVelocityInvalidation = computed<number | null>(() => state.value.lastVelocityInvalidation)
/**
@@ -193,6 +206,25 @@ export const useEventsStore = defineStore('events', () => {
}
}
const fetchSummaryProjection = async (timeframe: string = '24H', nodeId: string | null = null, sensorId: string | null = null): Promise<void> => {
if (summaryAbortController) summaryAbortController.abort()
summaryAbortController = new AbortController()
try {
state.value.isFetchingSummaryProjection = true
const params = new URLSearchParams({ timeframe, archived: appStore.viewingArchive ? 'true' : 'false' })
if (nodeId) params.append('nodeId', nodeId)
if (sensorId) params.append('sensorId', sensorId)
const response = await api.get(`/api/v1/events/summary?${params.toString()}`, { signal: summaryAbortController.signal })
state.value.summaryProjection = (await response.json()) as SummaryProjection
} catch (e: any) {
if (e.name !== 'AbortError') console.error('Summary fetch failed:', e)
} finally {
state.value.isFetchingSummaryProjection = false
}
}
const invalidateThreatVelocityProjection = (): void => {
state.value.lastVelocityInvalidation = Date.now()
}
@@ -260,9 +292,14 @@ export const useEventsStore = defineStore('events', () => {
// Refetch analytics projections so the charts zero out
fetchSeverityProjection('alltime', fleetStore.selectedNode?.id, fleetStore.selectedSensor?.sensorId)
fetchSummaryProjection('24H', fleetStore.selectedNode?.id, fleetStore.selectedSensor?.sensorId)
invalidateThreatVelocityProjection()
}
const clearSummaryProjection = (): void => {
state.value.summaryProjection = null
}
const handleWsEvent = (rawPayload: any): void => {
const payload = normalizeEvent(rawPayload)
@@ -286,6 +323,7 @@ export const useEventsStore = defineStore('events', () => {
if (affectsCurrentView) {
fetchSeverityProjection('alltime', selectedNode?.id, selectedSensor?.sensorId)
fetchSummaryProjection('24H', selectedNode?.id, selectedSensor?.sensorId)
invalidateThreatVelocityProjection()
}
}
@@ -297,18 +335,22 @@ export const useEventsStore = defineStore('events', () => {
isFetching,
severityProjection,
threatVelocityProjection,
summaryProjection,
isFetchingThreatVelocityProjection,
isFetchingSummaryProjection,
lastVelocityInvalidation,
filteredEvents,
fetchEvents,
fetchSeverityProjection,
fetchThreatVelocityProjection,
fetchSummaryProjection,
invalidateThreatVelocityProjection,
markAllRead,
markEventRead,
archiveEvent,
archiveAll,
purgeEvents,
clearSummaryProjection,
handleWsEvent,
}
})
-13
View File
@@ -8,7 +8,6 @@ export interface RawSensorPayload {
customName?: string
status?: string
isSilenced?: boolean
events24h?: number
envVars?: Record<string, any>
metadata?: Record<string, any>
lastHeartbeat?: string | null
@@ -59,7 +58,6 @@ export interface InstalledSensor {
display: string
status: string
isSilenced: boolean
events24h: number
envVars: Record<string, any>
metadata: Record<string, any>
lastHeartbeat: string | null
@@ -365,7 +363,6 @@ export const useFleetStore = defineStore('fleet', () => {
display: raw.customName || rawId,
status: raw.status || 'down',
isSilenced: raw.isSilenced ?? false,
events24h: raw.events24h ?? 0,
envVars: raw.envVars || {},
metadata: raw.metadata || {},
lastHeartbeat: raw.lastHeartbeat || null,
@@ -442,17 +439,7 @@ export const useFleetStore = defineStore('fleet', () => {
}
if (type === 'NEW_EVENT') {
const compositeSensorId = `${payload.nodeId}:${payload.sensorId}`
patchNode(payload.nodeId, { lastEvent: 'Just now' })
if (payload.events24h !== undefined) {
patchSensor(compositeSensorId, { events24h: payload.events24h })
} else {
const sensor = getSensor(payload.nodeId, payload.sensorId)
if (sensor) {
patchSensor(compositeSensorId, { events24h: (sensor.events24h || 0) + 1 })
}
}
return
}
+2 -8
View File
@@ -3,7 +3,6 @@ import { ref, computed } from 'vue'
import { api, ApiError } from '../../api/client'
export type SessionState = 'unknown' | 'authenticated' | 'unauthenticated'
export type ViewState = 'dashboard' | 'fleet' | 'settings' | 'node-detail'
export interface SystemStatePayload {
isArmed?: boolean
@@ -18,7 +17,6 @@ export interface AppState {
version: string
viewingArchive: boolean
sidebarOpen: boolean
currentView: ViewState
activeTimeframe: string
velocityTimeframe: string
sessionState: SessionState
@@ -36,7 +34,6 @@ export const useAppStore = defineStore('app', () => {
version: '1.0.0',
viewingArchive: false,
sidebarOpen: true,
currentView: 'dashboard',
activeTimeframe: '24H',
velocityTimeframe: '24H',
sessionState: 'unknown',
@@ -79,7 +76,6 @@ export const useAppStore = defineStore('app', () => {
const version = computed<string>(() => state.value.version)
const viewingArchive = computed<boolean>(() => state.value.viewingArchive)
const sidebarOpen = computed<boolean>(() => state.value.sidebarOpen)
const currentView = computed<ViewState>(() => state.value.currentView)
const activeTimeframe = computed<string>(() => state.value.activeTimeframe)
const velocityTimeframe = computed<string>(() => state.value.velocityTimeframe)
const sessionState = computed<SessionState>(() => state.value.sessionState)
@@ -183,7 +179,6 @@ export const useAppStore = defineStore('app', () => {
} catch (err) {
console.error('Logout request failed', err)
} finally {
transitionSession('unauthenticated')
window.location.href = '/'
}
}
@@ -263,7 +258,6 @@ export const useAppStore = defineStore('app', () => {
}
}
const toggleSidebar = (): void => { state.value.sidebarOpen = !state.value.sidebarOpen }
const setView = (view: ViewState): void => { state.value.currentView = view }
const toggleArchive = (): void => { state.value.viewingArchive = !state.value.viewingArchive }
const setVelocityTimeframe = (timeframe: string): void => { state.value.velocityTimeframe = timeframe }
@@ -274,11 +268,11 @@ export const useAppStore = defineStore('app', () => {
}
return {
isArmed, version, viewingArchive, sidebarOpen, currentView,
isArmed, version, viewingArchive, sidebarOpen,
activeTimeframe, velocityTimeframe, sessionState,
authError, setupError, requiresSetup, isInitialized, bootstrapError,
isAuthenticated, isReady, isBootstrapping, canAccessDashboard,
toggleTheme, toggleSidebar, setView, toggleArchive, setVelocityTimeframe,
toggleTheme, toggleSidebar, toggleArchive, setVelocityTimeframe,
toggleArmed, changePassword, factoryReset, login, logout, completeSetup,
initAppStore, fetchSystemState, enableDebugSetup
}
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useAppStore } from '../stores/System/app.ts'
import { useRouter } from 'vue-router'
import { useFleetStore } from '../stores/Fleet/fleet.ts'
import type { FleetNode } from '../stores/Fleet/fleet.ts'
import PageHeader from '../components/ui/layout/PageHeader.vue'
@@ -10,8 +10,8 @@ import FleetSkeleton from '../components/fleetmanagement/FleetSkeleton.vue'
import FleetDeployModal from '../components/fleetmanagement/FleetDeployModal.vue'
import FleetNodeWidget from '../components/fleetmanagement/FleetNodeWidget.vue'
const appStore = useAppStore()
const fleetStore = useFleetStore()
const router = useRouter()
// --- MANIFEST CATALOG ---
const isManifestLoading = ref(true)
@@ -72,7 +72,7 @@ const handleForgetNode = async (nodeId: string) => {
const handleOpenNodeDetail = (nodeId: string) => {
fleetStore.selectTarget(nodeId, null, false)
appStore.setView('node-detail')
router.push({ name: 'node-detail', params: { id: nodeId } })
}
</script>
+23 -11
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useAppStore } from '../stores/System/app.ts'
import { useRoute, useRouter } from 'vue-router'
import { useFleetStore } from '../stores/Fleet/fleet.ts'
import { useEventsStore } from '../stores/Events/events.ts'
import { useAppStore } from '../stores/System/app.ts'
import { useConfigStore } from '../stores/Config/config.ts'
import type { FleetNode } from '../stores/Fleet/fleet.ts'
@@ -14,10 +15,12 @@ import NodeKeyModal from '../components/nodedetails/NodeKeyModal.vue'
import NodeSyncModal from '../components/nodedetails/NodeSyncModal.vue'
import NodeSensorModal from '../components/nodedetails/NodeSensorModal.vue'
const appStore = useAppStore()
const fleetStore = useFleetStore()
const eventsStore = useEventsStore()
const configStore = useConfigStore()
const appStore = useAppStore()
const route = useRoute()
const router = useRouter()
const selectedNodeId = computed(() => fleetStore.selectedNodeId)
@@ -148,7 +151,7 @@ const handleDeleteNode = async () => {
if (confirm(`Delete node "${node.value.alias}"? This cannot be undone.`)) {
const res = await fleetStore.deleteNode(node.value.id)
if (res.success) {
appStore.setView('fleet')
router.push('/fleet')
} else {
alert(res.error)
}
@@ -157,21 +160,30 @@ const handleDeleteNode = async () => {
const viewAllEvents = () => {
// Keeps the current node selected in the fleetStore to act as a filter
appStore.setView('dashboard')
router.push('/dashboard')
}
// --- NAVIGATION ---
watch(selectedNodeId, async (value) => {
if (!value) {
if (appStore.currentView === 'node-detail') {
appStore.setView('fleet')
watch(() => route.params.id, async (newId, oldId) => {
if (newId) {
if (newId !== oldId) {
eventsStore.clearSummaryProjection()
}
return
fleetStore.selectTarget(newId as string, null, false)
await Promise.all([
fleetStore.fetchNodeDetails(newId as string),
eventsStore.fetchSummaryProjection('24H', newId as string)
])
}
await fleetStore.fetchNodeDetails(value)
}, { immediate: true })
watch(() => appStore.viewingArchive, async () => {
if (route.params.id) {
await eventsStore.fetchSummaryProjection('24H', route.params.id as string)
}
})
const timeAgo = (dateStr: string) => {
if (!dateStr) return 'Unknown'
const diff = Math.floor((new Date().getTime() - new Date(dateStr).getTime()) / 1000)
@@ -271,7 +283,7 @@ const closeSensor = () => {
<div class="min-h-full flex flex-col max-w-[1600px] w-full mx-auto px-2 sm:px-4 lg:px-6 pb-4 sm:pb-6">
<div class="mt-4 sm:mt-6 mb-4 shrink-0">
<button @click="fleetStore.clearSelection()" class="flex items-center gap-1.5 text-sm font-medium text-text-m hover:text-text-h transition-colors outline-none w-max">
<button @click="fleetStore.clearSelection(); router.push('/fleet')" class="flex items-center gap-1.5 text-sm font-medium text-text-m hover:text-text-h transition-colors outline-none w-max">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Back to Fleet
</button>