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
|
||||
// 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