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'] });
+ },
+ });
+}