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