diff --git a/ui/src/api/queries/alerts.test.tsx b/ui/src/api/queries/alerts.test.tsx new file mode 100644 index 00000000..7ae91516 --- /dev/null +++ b/ui/src/api/queries/alerts.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useEnvironmentStore } from '../environment-store'; + +vi.mock('../client', () => ({ + api: { GET: vi.fn(), POST: vi.fn() }, +})); + +import { api as apiClient } from '../client'; +import { useAlerts, useUnreadCount } from './alerts'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('useAlerts', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('fetches alerts for selected env and passes filter query params', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook( + () => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }), + { wrapper }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts', + expect.objectContaining({ + params: expect.objectContaining({ + path: { envSlug: 'dev' }, + query: expect.objectContaining({ + state: ['FIRING'], + severity: ['CRITICAL', 'WARNING'], + limit: 100, + }), + }), + }), + ); + }); + + it('does not fetch when no env is selected', () => { + useEnvironmentStore.setState({ environment: undefined }); + const { result } = renderHook(() => useAlerts(), { wrapper }); + expect(result.current.fetchStatus).toBe('idle'); + expect(apiClient.GET).not.toHaveBeenCalled(); + }); +}); + +describe('useUnreadCount', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('returns the server payload unmodified', async () => { + (apiClient.GET as any).mockResolvedValue({ + data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } }, + error: null, + }); + const { result } = renderHook(() => useUnreadCount(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual({ + total: 3, + bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 }, + }); + }); +}); diff --git a/ui/src/api/queries/alerts.ts b/ui/src/api/queries/alerts.ts new file mode 100644 index 00000000..d2541c3d --- /dev/null +++ b/ui/src/api/queries/alerts.ts @@ -0,0 +1,168 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertDto = components['schemas']['AlertDto']; +export type UnreadCountResponse = components['schemas']['UnreadCountResponse']; + +type AlertState = NonNullable; +type AlertSeverity = NonNullable; + +export interface AlertsFilter { + state?: AlertState | AlertState[]; + severity?: AlertSeverity | AlertSeverity[]; + ruleId?: string; + limit?: number; +} + +function toArray(v: T | T[] | undefined): T[] | undefined { + if (v === undefined) return undefined; + return Array.isArray(v) ? v : [v]; +} + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert endpoints +// emits `path?: never` plus a `query.env: Environment` parameter because the +// server resolves the env via the `@EnvPath` argument resolver, which the +// OpenAPI scanner does not recognise as a path variable. At runtime the URL +// template `{envSlug}` is substituted from `params.path.envSlug` by +// openapi-fetch regardless of what the TS types say; we therefore cast the +// call options to `any` to bypass the generated type oddity. + +/** List alert instances in the current env. Polls every 30s (pauses in background). */ +export function useAlerts(filter: AlertsFilter = {}) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, filter], + enabled: !!env, + refetchInterval: 30_000, + refetchIntervalInBackground: false, + queryFn: async () => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts', + { + params: { + path: { envSlug: env }, + query: { + state: toArray(filter.state), + severity: toArray(filter.severity), + ruleId: filter.ruleId, + limit: filter.limit ?? 100, + }, + }, + } as any, + ); + if (error) throw error; + return data as AlertDto[]; + }, + }); +} + +/** Fetch a single alert instance by id. */ +export function useAlert(id: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, 'detail', id], + enabled: !!env && !!id, + queryFn: async () => { + if (!env || !id) throw new Error('no env/id'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + return data as AlertDto; + }, + }); +} + +/** Unread alert count for the current env. Polls every 30s (pauses in background). */ +export function useUnreadCount() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alerts', env, 'unread-count'], + enabled: !!env, + refetchInterval: 30_000, + refetchIntervalInBackground: false, + queryFn: async () => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/unread-count', + { + params: { path: { envSlug: env } }, + } as any, + ); + if (error) throw error; + return data as UnreadCountResponse; + }, + }); +} + +/** Acknowledge a single alert instance. */ +export function useAckAlert() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/{id}/ack', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + return data as AlertDto; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + }, + }); +} + +/** Mark a single alert instance as read (inbox semantics). */ +export function useMarkAlertRead() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.POST( + '/environments/{envSlug}/alerts/{id}/read', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} + +/** Mark a batch of alert instances as read. */ +export function useBulkReadAlerts() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (ids: string[]) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.POST( + '/environments/{envSlug}/alerts/bulk-read', + { + params: { path: { envSlug: env } }, + body: { alertInstanceIds: ids }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +}