From c6c3dd9cfe70e163658d4edaf99e9e58cc0a91d6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:13:07 +0200 Subject: [PATCH] feat(ui/alerts): alert rule query hooks (CRUD, enable/disable, preview, test-evaluate) Co-Authored-By: Claude Opus 4.7 (1M context) --- ui/src/api/queries/alertRules.test.tsx | 66 +++++++++ ui/src/api/queries/alertRules.ts | 187 +++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 ui/src/api/queries/alertRules.test.tsx create mode 100644 ui/src/api/queries/alertRules.ts diff --git a/ui/src/api/queries/alertRules.test.tsx b/ui/src/api/queries/alertRules.test.tsx new file mode 100644 index 00000000..3ed12c77 --- /dev/null +++ b/ui/src/api/queries/alertRules.test.tsx @@ -0,0 +1,66 @@ +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 { useAlertRules, useSetAlertRuleEnabled } from './alertRules'; + +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return {children}; +} + +describe('useAlertRules', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'prod' }); + }); + + it('fetches rules for selected env', async () => { + (apiClient.GET as any).mockResolvedValue({ data: [], error: null }); + const { result } = renderHook(() => useAlertRules(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.GET).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules', + { params: { path: { envSlug: 'prod' } } }, + ); + }); +}); + +describe('useSetAlertRuleEnabled', () => { + beforeEach(() => { + vi.clearAllMocks(); + useEnvironmentStore.setState({ environment: 'prod' }); + }); + + it('POSTs to /enable when enabling', async () => { + (apiClient.POST as any).mockResolvedValue({ error: null }); + const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper }); + await result.current.mutateAsync({ id: 'r1', enabled: true }); + expect(apiClient.POST).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules/{id}/enable', + { params: { path: { envSlug: 'prod', id: 'r1' } } }, + ); + }); + + it('POSTs to /disable when disabling', async () => { + (apiClient.POST as any).mockResolvedValue({ error: null }); + const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper }); + await result.current.mutateAsync({ id: 'r1', enabled: false }); + expect(apiClient.POST).toHaveBeenCalledWith( + '/environments/{envSlug}/alerts/rules/{id}/disable', + { params: { path: { envSlug: 'prod', id: 'r1' } } }, + ); + }); +}); diff --git a/ui/src/api/queries/alertRules.ts b/ui/src/api/queries/alertRules.ts new file mode 100644 index 00000000..2db466c8 --- /dev/null +++ b/ui/src/api/queries/alertRules.ts @@ -0,0 +1,187 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { components } from '../schema'; +import { apiClient, useSelectedEnv } from './alertMeta'; + +export type AlertRuleResponse = components['schemas']['AlertRuleResponse']; +export type AlertRuleRequest = components['schemas']['AlertRuleRequest']; +export type RenderPreviewRequest = components['schemas']['RenderPreviewRequest']; +export type RenderPreviewResponse = components['schemas']['RenderPreviewResponse']; +export type TestEvaluateRequest = components['schemas']['TestEvaluateRequest']; +export type TestEvaluateResponse = components['schemas']['TestEvaluateResponse']; +export type AlertCondition = AlertRuleResponse['condition']; +export type ConditionKind = AlertRuleResponse['conditionKind']; + +// NOTE ON TYPES: the generated OpenAPI schema for env-scoped alert-rule 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 rules in the current env. */ +export function useAlertRules() { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertRules', env], + enabled: !!env, + queryFn: async () => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/rules', + { + params: { path: { envSlug: env } }, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse[]; + }, + }); +} + +/** Fetch a single alert rule by id. */ +export function useAlertRule(id: string | undefined) { + const env = useSelectedEnv(); + return useQuery({ + queryKey: ['alertRules', env, id], + enabled: !!env && !!id, + queryFn: async () => { + if (!env || !id) throw new Error('no env/id'); + const { data, error } = await apiClient.GET( + '/environments/{envSlug}/alerts/rules/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse; + }, + }); +} + +/** Create a new alert rule in the current env. */ +export function useCreateAlertRule() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertRuleRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules', + { + params: { path: { envSlug: env } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + }, + }); +} + +/** Update an existing alert rule. */ +export function useUpdateAlertRule(id: string) { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async (req: AlertRuleRequest) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.PUT( + '/environments/{envSlug}/alerts/rules/{id}', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as AlertRuleResponse; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + qc.invalidateQueries({ queryKey: ['alertRules', env, id] }); + }, + }); +} + +/** Delete an alert rule. */ +export function useDeleteAlertRule() { + 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/rules/{id}', + { + params: { path: { envSlug: env, id } }, + } as any, + ); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + }, + }); +} + +/** Enable or disable an alert rule. Routes to /enable or /disable based on the flag. */ +export function useSetAlertRuleEnabled() { + const qc = useQueryClient(); + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => { + if (!env) throw new Error('no env'); + const path = enabled + ? '/environments/{envSlug}/alerts/rules/{id}/enable' + : '/environments/{envSlug}/alerts/rules/{id}/disable'; + const { error } = await apiClient.POST(path, { + params: { path: { envSlug: env, id } }, + } as any); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alertRules', env] }); + }, + }); +} + +/** Render a preview of the notification title + message for a rule using the provided context. */ +export function useRenderPreview() { + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, req }: { id: string; req: RenderPreviewRequest }) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules/{id}/render-preview', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as RenderPreviewResponse; + }, + }); +} + +/** Test-evaluate a rule (dry-run) without persisting an alert instance. */ +export function useTestEvaluate() { + const env = useSelectedEnv(); + return useMutation({ + mutationFn: async ({ id, req }: { id: string; req: TestEvaluateRequest }) => { + if (!env) throw new Error('no env'); + const { data, error } = await apiClient.POST( + '/environments/{envSlug}/alerts/rules/{id}/test-evaluate', + { + params: { path: { envSlug: env, id } }, + body: req, + } as any, + ); + if (error) throw error; + return data as TestEvaluateResponse; + }, + }); +}