feat(frontend): add React Query hooks and WebSocket alert feed

Hooks for threats list/detail, stats, model status/retrain, and live
WebSocket alert stream with auto-reconnect and ring buffer.
This commit is contained in:
CarterPerez-dev 2026-02-28 11:32:23 -05:00
parent cdeb1451ef
commit 3b4f6ce8a6
5 changed files with 228 additions and 0 deletions

View File

@ -2,3 +2,8 @@
// © AngelaMos | 2026
// index.ts
// ===================
export * from './useThreats'
export * from './useStats'
export * from './useModels'
export * from './useAlerts'

View File

@ -0,0 +1,105 @@
// ===================
// © AngelaMos | 2026
// useAlerts.ts
// ===================
import { useEffect, useRef } from 'react'
import { create } from 'zustand'
import { ALERTS, WS_ENDPOINTS } from '@/config'
import { WebSocketAlertSchema, type WebSocketAlert } from '@/api/types'
interface AlertState {
alerts: WebSocketAlert[]
isConnected: boolean
connectionError: string | null
addAlert: (alert: WebSocketAlert) => void
setConnected: (connected: boolean) => void
setError: (error: string | null) => void
clear: () => void
}
const useAlertStore = create<AlertState>()((set) => ({
alerts: [],
isConnected: false,
connectionError: null,
addAlert: (alert) =>
set((state) => ({
alerts: [alert, ...state.alerts].slice(0, ALERTS.MAX_ITEMS),
})),
setConnected: (connected) =>
set({ isConnected: connected, connectionError: null }),
setError: (error) =>
set({ isConnected: false, connectionError: error }),
clear: () =>
set({ alerts: [], isConnected: false, connectionError: null }),
}))
function getWsUrl(): string {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}${WS_ENDPOINTS.ALERTS}`
}
export function useAlerts() {
const wsRef = useRef<WebSocket | null>(null)
const retryCountRef = useRef(0)
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { alerts, isConnected, connectionError } = useAlertStore()
const { addAlert, setConnected, setError, clear } = useAlertStore()
useEffect(() => {
function connect() {
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
ws.onopen = () => {
retryCountRef.current = 0
setConnected(true)
}
ws.onmessage = (event) => {
const parsed = WebSocketAlertSchema.safeParse(JSON.parse(event.data))
if (parsed.success) {
addAlert(parsed.data)
}
}
ws.onclose = () => {
setConnected(false)
scheduleReconnect()
}
ws.onerror = () => {
setError('WebSocket connection failed')
ws.close()
}
}
function scheduleReconnect() {
const delay = Math.min(
ALERTS.RECONNECT_BASE_MS * 2 ** retryCountRef.current,
ALERTS.RECONNECT_MAX_MS,
)
retryCountRef.current += 1
retryTimerRef.current = setTimeout(connect, delay)
}
connect()
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current)
}
if (wsRef.current) {
wsRef.current.close()
}
clear()
}
}, [addAlert, setConnected, setError, clear])
return { alerts, isConnected, connectionError }
}

View File

@ -0,0 +1,43 @@
// ===================
// © AngelaMos | 2026
// useModels.ts
// ===================
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { QUERY_KEYS, API_ENDPOINTS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
import type { ModelStatus, RetrainResponse } from '@/api/types'
export function useModelStatus() {
return useQuery<ModelStatus>({
queryKey: QUERY_KEYS.MODELS.STATUS(),
queryFn: async () => {
const { data } = await apiClient.get<ModelStatus>(
API_ENDPOINTS.MODELS.STATUS,
)
return data
},
...QUERY_STRATEGIES.standard,
})
}
export function useRetrain() {
const queryClient = useQueryClient()
return useMutation<RetrainResponse>({
mutationFn: async () => {
const { data } = await apiClient.post<RetrainResponse>(
API_ENDPOINTS.MODELS.RETRAIN,
)
return data
},
onSuccess: () => {
toast.success('Retraining started')
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MODELS.ALL })
},
onError: () => {
toast.error('Failed to start retraining')
},
})
}

View File

@ -0,0 +1,23 @@
// ===================
// © AngelaMos | 2026
// useStats.ts
// ===================
import { useQuery } from '@tanstack/react-query'
import { QUERY_KEYS, API_ENDPOINTS } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
import type { StatsResponse } from '@/api/types'
export function useStats(range = '24h') {
return useQuery<StatsResponse>({
queryKey: QUERY_KEYS.STATS.BY_RANGE(range),
queryFn: async () => {
const { data } = await apiClient.get<StatsResponse>(
API_ENDPOINTS.STATS,
{ params: { range } },
)
return data
},
...QUERY_STRATEGIES.frequent,
})
}

View File

@ -0,0 +1,52 @@
// ===================
// © AngelaMos | 2026
// useThreats.ts
// ===================
import { useQuery } from '@tanstack/react-query'
import { QUERY_KEYS, API_ENDPOINTS, PAGINATION } from '@/config'
import { apiClient, QUERY_STRATEGIES } from '@/core/api'
import type { ThreatList, ThreatEvent } from '@/api/types'
interface ThreatParams {
limit?: number
offset?: number
severity?: 'HIGH' | 'MEDIUM' | 'LOW'
source_ip?: string
since?: string
until?: string
}
export function useThreats(params: ThreatParams = {}) {
const queryParams = {
limit: params.limit ?? PAGINATION.DEFAULT_LIMIT,
offset: params.offset ?? 0,
...params,
}
return useQuery<ThreatList>({
queryKey: QUERY_KEYS.THREATS.LIST(queryParams),
queryFn: async () => {
const { data } = await apiClient.get<ThreatList>(
API_ENDPOINTS.THREATS.LIST,
{ params: queryParams },
)
return data
},
...QUERY_STRATEGIES.frequent,
})
}
export function useThreat(id: string | null) {
return useQuery<ThreatEvent>({
queryKey: QUERY_KEYS.THREATS.BY_ID(id ?? ''),
queryFn: async () => {
const { data } = await apiClient.get<ThreatEvent>(
API_ENDPOINTS.THREATS.BY_ID(id!),
)
return data
},
enabled: id !== null,
...QUERY_STRATEGIES.standard,
})
}