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:
parent
cdeb1451ef
commit
3b4f6ce8a6
|
|
@ -2,3 +2,8 @@
|
||||||
// © AngelaMos | 2026
|
// © AngelaMos | 2026
|
||||||
// index.ts
|
// index.ts
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
export * from './useThreats'
|
||||||
|
export * from './useStats'
|
||||||
|
export * from './useModels'
|
||||||
|
export * from './useAlerts'
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue