diff --git a/ui/src/api/queries/alertNotifications.ts b/ui/src/api/queries/alertNotifications.ts new file mode 100644 index 00000000..68f86ea8 --- /dev/null +++ b/ui/src/api/queries/alertNotifications.ts @@ -0,0 +1,54 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertNotificationDto = components['schemas']['AlertNotificationDto']; + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-notification +// 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` on each call to bypass the generated type oddity. + +/** List notifications for a given alert instance. */ +export function useAlertNotifications(alertId: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertNotifications', env, alertId], + enabled: !!env && !!alertId, + queryFn: async () => { + if (!env || !alertId) throw new Error('no env/alertId'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/{alertId}/notifications', + { + params: { path: { envSlug: env, alertId } }, + } as any, + ); + if (error) throw error; + return data as AlertNotificationDto[]; + }, + }); +} + +/** Retry a failed notification. Uses the flat path — notification IDs are + * globally unique across environments, so the endpoint is not env-scoped. + */ +export function useRetryNotification() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await apiClient.POST( + '/alerts/notifications/{id}/retry', + { + params: { path: { id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertNotifications'] }); + }, + }); +} diff --git a/ui/src/api/queries/alertSilences.test.tsx b/ui/src/api/queries/alertSilences.test.tsx new file mode 100644 index 00000000..d9f5b207 --- /dev/null +++ b/ui/src/api/queries/alertSilences.test.tsx @@ -0,0 +1,39 @@ +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(), PUT: vi.fn(), DELETE: vi.fn() }, +})); + +import { api as apiClient } from '../client'; +import { useAlertSilences } from './alertSilences'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return {children}; +} + +describe('useAlertSilences', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'dev' }); + }); + + it('fetches silences for selected env', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook(() => useAlertSilences(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/silences', + { params: { path: { envSlug: 'dev' } } }, + ); + }); +}); diff --git a/ui/src/api/queries/alertSilences.ts b/ui/src/api/queries/alertSilences.ts new file mode 100644 index 00000000..ce03dcab --- /dev/null +++ b/ui/src/api/queries/alertSilences.ts @@ -0,0 +1,101 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertSilenceResponse = components['schemas']['AlertSilenceResponse']; +export type AlertSilenceRequest = components['schemas']['AlertSilenceRequest']; + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-silence +// 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` on each call to bypass the generated type oddity. + +/** List alert silences in the current env. */ +export function useAlertSilences() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertSilences', env], + enabled: !!env, + queryFn: async () => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/silences', + { + params: { path: { envSlug: env } }, + } as any, + ); + if (error) throw error; + return data as AlertSilenceResponse[]; + }, + }); +} + +/** Create a new alert silence in the current env. */ +export function useCreateSilence() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertSilenceRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/silences', + { + params: { path: { envSlug: env } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertSilenceResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertSilences', env] }); + }, + }); +} + +/** Update an existing alert silence. */ +export function useUpdateSilence(id: string) { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertSilenceRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.PUT( + '/environments/{envSlug}/alerts/silences/{id}', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertSilenceResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertSilences', env] }); + }, + }); +} + +/** Delete an alert silence. */ +export function useDeleteSilence() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.DELETE( + '/environments/{envSlug}/alerts/silences/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertSilences', env] }); + }, + }); +}