diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 40777676c..f192459cc 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -145,6 +145,7 @@ export enum ActionsEnum { updateEventStreamingDestination = "updateEventStreamingDestination", deleteEventStreamingDestination = "deleteEventStreamingDestination", listEventStreamingDestinations = "listEventStreamingDestinations", + listHealthChecks = "listHealthChecks", createAlertRule = "createAlertRule", updateAlertRule = "updateAlertRule", deleteAlertRule = "deleteAlertRule", diff --git a/server/routers/external.ts b/server/routers/external.ts index d7729bca5..484db4344 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -427,6 +427,13 @@ authenticated.get( resource.listResources ); +authenticated.get( + "/org/:orgId/health-checks", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listHealthChecks), + resource.listHealthChecks +); + authenticated.get( "/org/:orgId/resource-names", verifyOrgAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 12e98a70d..2b379a7d5 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -32,3 +32,4 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./listHealthChecks"; diff --git a/server/routers/resource/listHealthChecks.ts b/server/routers/resource/listHealthChecks.ts new file mode 100644 index 000000000..698f35052 --- /dev/null +++ b/server/routers/resource/listHealthChecks.ts @@ -0,0 +1,138 @@ +import { db, targetHealthCheck, targets, resources } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq, sql, inArray } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const listHealthChecksParamsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const listHealthChecksSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +export type ListHealthChecksResponse = { + healthChecks: { + targetHealthCheckId: number; + resourceId: number; + resourceName: string; + hcEnabled: boolean; + hcHealth: "unknown" | "healthy" | "unhealthy"; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/health-checks", + description: "List health checks for all resources in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.PublicResource], + request: { + params: listHealthChecksParamsSchema, + query: listHealthChecksSchema + }, + responses: {} +}); + +export async function listHealthChecks( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listHealthChecksSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listHealthChecksParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const list = await db + .select({ + targetHealthCheckId: targetHealthCheck.targetHealthCheckId, + resourceId: resources.resourceId, + resourceName: resources.name, + hcEnabled: targetHealthCheck.hcEnabled, + hcHealth: targetHealthCheck.hcHealth + }) + .from(targetHealthCheck) + .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) + .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .where(eq(resources.orgId, orgId)) + .orderBy(sql`${resources.name} ASC`) + .limit(limit) + .offset(offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(targetHealthCheck) + .innerJoin(targets, eq(targets.targetId, targetHealthCheck.targetId)) + .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .where(eq(resources.orgId, orgId)); + + return response(res, { + data: { + healthChecks: list.map((row) => ({ + targetHealthCheckId: row.targetHealthCheckId, + resourceId: row.resourceId, + resourceName: row.resourceName, + hcEnabled: row.hcEnabled, + hcHealth: (row.hcHealth ?? "unknown") as + | "unknown" + | "healthy" + | "unhealthy" + })), + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Health checks retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx index b9379f7cc..c9ef938d5 100644 --- a/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx +++ b/src/app/[orgId]/settings/alerting/[ruleId]/page.tsx @@ -1,33 +1,60 @@ "use client"; import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; -import { ruleToFormValues } from "@app/lib/alertRuleForm"; -import type { AlertRule } from "@app/lib/alertRulesLocalStorage"; -import { getRule } from "@app/lib/alertRulesLocalStorage"; +import { apiResponseToFormValues } from "@app/lib/alertRuleForm"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; +import type { AxiosResponse } from "axios"; +import type { GetAlertRuleResponse } from "@server/private/routers/alertRule"; +import type { AlertRuleFormValues } from "@app/lib/alertRuleForm"; export default function EditAlertRulePage() { const t = useTranslations(); const params = useParams(); const router = useRouter(); const orgId = params.orgId as string; - const ruleId = params.ruleId as string; - const [rule, setRule] = useState(undefined); + const ruleIdParam = params.ruleId as string; + const alertRuleId = parseInt(ruleIdParam, 10); + + const api = createApiClient(useEnvContext()); + + const [formValues, setFormValues] = useState(undefined); useEffect(() => { - const r = getRule(orgId, ruleId); - setRule(r ?? null); - }, [orgId, ruleId]); + if (isNaN(alertRuleId)) { + router.replace(`/${orgId}/settings/alerting`); + return; + } + + api.get>( + `/org/${orgId}/alert-rule/${alertRuleId}` + ) + .then((res) => { + const rule = res.data.data; + setFormValues(apiResponseToFormValues(rule)); + }) + .catch((e) => { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + setFormValues(null); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgId, alertRuleId]); useEffect(() => { - if (rule === null) { + if (formValues === null) { router.replace(`/${orgId}/settings/alerting`); } - }, [rule, orgId, router]); + }, [formValues, orgId, router]); - if (rule === undefined) { + if (formValues === undefined) { return (
{t("loading")} @@ -35,17 +62,16 @@ export default function EditAlertRulePage() { ); } - if (rule === null) { + if (formValues === null) { return null; } return ( ); diff --git a/src/app/[orgId]/settings/alerting/create/page.tsx b/src/app/[orgId]/settings/alerting/create/page.tsx index 24c0d2ffe..fc5c51660 100644 --- a/src/app/[orgId]/settings/alerting/create/page.tsx +++ b/src/app/[orgId]/settings/alerting/create/page.tsx @@ -2,39 +2,17 @@ import AlertRuleGraphEditor from "@app/components/alert-rule-editor/AlertRuleGraphEditor"; import { defaultFormValues } from "@app/lib/alertRuleForm"; -import { isoNow, newRuleId } from "@app/lib/alertRulesLocalStorage"; import { useParams } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; export default function NewAlertRulePage() { - const t = useTranslations(); const params = useParams(); const orgId = params.orgId as string; - const [meta, setMeta] = useState<{ id: string; createdAt: string } | null>( - null - ); - - useEffect(() => { - setMeta({ id: newRuleId(), createdAt: isoNow() }); - }, []); - - if (!meta) { - return ( -
- {t("loading")} -
- ); - } return ( ); -} +} \ No newline at end of file diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 97e1d1755..a28f45563 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -11,123 +11,131 @@ import { } from "@app/components/ui/dropdown-menu"; import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; -import { - type AlertRule, - deleteRule, - isoNow, - loadRules, - upsertRule -} from "@app/lib/alertRulesLocalStorage"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { orgQueries } from "@app/lib/queries"; import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import moment from "moment"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useState } from "react"; -import { Badge } from "@app/components/ui/badge"; +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; type AlertingRulesTableProps = { orgId: string; }; -function ruleHref(orgId: string, ruleId: string) { +type AlertRuleRow = { + alertRuleId: number; + orgId: string; + name: string; + eventType: string; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; +}; + +function ruleHref(orgId: string, ruleId: number) { return `/${orgId}/settings/alerting/${ruleId}`; } function sourceSummary( - rule: AlertRule, + rule: AlertRuleRow, t: (k: string, o?: Record) => string ) { - if (rule.source.type === "site") { - return t("alertingSummarySites", { - count: rule.source.siteIds.length - }); + if ( + rule.eventType === "site_online" || + rule.eventType === "site_offline" + ) { + return t("alertingSummarySites", { count: rule.siteIds.length }); } return t("alertingSummaryHealthChecks", { - count: rule.source.targetIds.length + count: rule.healthCheckIds.length }); } -function triggerLabel(rule: AlertRule, t: (k: string) => string) { - switch (rule.trigger) { +function triggerLabel( + rule: AlertRuleRow, + t: (k: string) => string +) { + switch (rule.eventType) { case "site_online": return t("alertingTriggerSiteOnline"); case "site_offline": return t("alertingTriggerSiteOffline"); case "health_check_healthy": return t("alertingTriggerHcHealthy"); - case "health_check_unhealthy": + case "health_check_not_healthy": return t("alertingTriggerHcUnhealthy"); default: - return rule.trigger; + return rule.eventType; } } -function actionBadges(rule: AlertRule, t: (k: string) => string) { - return rule.actions.map((a, i) => { - if (a.type === "notify") { - return ( - - {t("alertingActionNotify")} - - ); - } - if (a.type === "sms") { - return ( - - {t("alertingActionSms")} - - ); - } - return ( - - {t("alertingActionWebhook")} - - ); - }); -} - export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { const router = useRouter(); const t = useTranslations(); - const [rows, setRows] = useState([]); + const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); + const [deleteOpen, setDeleteOpen] = useState(false); - const [selected, setSelected] = useState(null); - const [isRefreshing, setIsRefreshing] = useState(false); + const [selected, setSelected] = useState(null); + const [togglingId, setTogglingId] = useState(null); - const refreshFromStorage = useCallback(() => { - setRows(loadRules(orgId)); - }, [orgId]); + const { + data: rows = [], + isLoading, + refetch, + isRefetching + } = useQuery(orgQueries.alertRules({ orgId })); - useEffect(() => { - refreshFromStorage(); - }, [refreshFromStorage]); + const invalidate = () => + queryClient.invalidateQueries(orgQueries.alertRules({ orgId })); - const refreshData = async () => { - setIsRefreshing(true); + const setEnabled = async (rule: AlertRuleRow, enabled: boolean) => { + setTogglingId(rule.alertRuleId); try { - await new Promise((r) => setTimeout(r, 200)); - refreshFromStorage(); + await api.post(`/org/${orgId}/alert-rule/${rule.alertRuleId}`, { + enabled + }); + await invalidate(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); } finally { - setIsRefreshing(false); + setTogglingId(null); } }; - const setEnabled = (rule: AlertRule, enabled: boolean) => { - upsertRule(orgId, { ...rule, enabled, updatedAt: isoNow() }); - refreshFromStorage(); - }; - const confirmDelete = async () => { if (!selected) return; - deleteRule(orgId, selected.id); - refreshFromStorage(); - setDeleteOpen(false); - setSelected(null); - toast({ title: t("alertingRuleDeleted") }); + try { + await api.delete( + `/org/${orgId}/alert-rule/${selected.alertRuleId}` + ); + await invalidate(); + toast({ title: t("alertingRuleDeleted") }); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeleteOpen(false); + setSelected(null); + } }; - const columns: ExtendedColumnDef[] = [ + const columns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, @@ -163,18 +171,6 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { ), cell: ({ row }) => {triggerLabel(row.original, t)} }, - { - id: "actionsCol", - friendlyName: t("alertingColumnActions"), - header: () => ( - {t("alertingColumnActions")} - ), - cell: ({ row }) => ( -
- {actionBadges(row.original, t)} -
- ) - }, { accessorKey: "enabled", friendlyName: t("alertingColumnEnabled"), @@ -186,6 +182,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { return ( setEnabled(r, v)} /> ); @@ -230,7 +227,7 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { @@ -270,8 +267,8 @@ export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) { onAdd={() => { router.push(`/${orgId}/settings/alerting/create`); }} - onRefresh={refreshData} - isRefreshing={isRefreshing} + onRefresh={() => refetch()} + isRefreshing={isRefetching || isLoading} addButtonText={t("alertingAddRule")} enableColumnVisibility stickyLeftColumn="name" diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 5a2e42393..ec57ae065 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -172,9 +172,7 @@ function SiteMultiSelect({ ); } -const ALERT_RESOURCES_PAGE_SIZE = 10; - -function ResourceTenMultiSelect({ +function HealthCheckMultiSelect({ orgId, value, onChange @@ -185,58 +183,46 @@ function ResourceTenMultiSelect({ }) { const t = useTranslations(); const [open, setOpen] = useState(false); - const { data: resources = [] } = useQuery( - orgQueries.resources({ - orgId, - perPage: ALERT_RESOURCES_PAGE_SIZE - }) + const [q, setQ] = useState(""); + const [debounced] = useDebounce(q, 150); + + const { data: healthChecks = [] } = useQuery( + orgQueries.healthChecks({ orgId }) ); - const rows = useMemo(() => { - const out: { - resourceId: number; - name: string; - targetIds: number[]; - }[] = []; - for (const r of resources) { - const targetIds = r.targets.map((x) => x.targetId); - if (targetIds.length > 0) { - out.push({ - resourceId: r.resourceId, - name: r.name, - targetIds - }); - } + + const shown = useMemo(() => { + const query = debounced.trim().toLowerCase(); + const base = query + ? healthChecks.filter((hc) => + hc.resourceName.toLowerCase().includes(query) + ) + : healthChecks; + // Always keep already-selected items visible even if they fall outside the search + if (query && value.length > 0) { + const selectedNotInBase = healthChecks.filter( + (hc) => + value.includes(hc.targetHealthCheckId) && + !base.some( + (b) => b.targetHealthCheckId === hc.targetHealthCheckId + ) + ); + return [...selectedNotInBase, ...base]; } - return out; - }, [resources]); + return base; + }, [healthChecks, debounced, value]); - const selectedResourceCount = useMemo( - () => - rows.filter( - (row) => - row.targetIds.length > 0 && - row.targetIds.every((id) => value.includes(id)) - ).length, - [rows, value] - ); - - const toggle = (targetIds: number[]) => { - const allOn = - targetIds.length > 0 && - targetIds.every((id) => value.includes(id)); - if (allOn) { - onChange(value.filter((id) => !targetIds.includes(id))); + const toggle = (id: number) => { + if (value.includes(id)) { + onChange(value.filter((x) => x !== id)); } else { - onChange([...new Set([...value, ...targetIds])]); + onChange([...value, id]); } }; const summary = - selectedResourceCount === 0 - ? t("alertingSelectResources") - : t("alertingResourcesSelected", { - count: selectedResourceCount - }); + value.length === 0 + ? t("alertingSelectHealthChecks") + : t("alertingHealthChecksSelected", { count: value.length }); return ( @@ -255,38 +241,42 @@ function ResourceTenMultiSelect({ className="w-[var(--radix-popover-trigger-width)] p-0" align="start" > -
- {rows.length === 0 ? ( -

- {t("alertingResourcesEmpty")} -

- ) : ( - rows.map((row) => { - const checked = - row.targetIds.length > 0 && - row.targetIds.every((id) => - value.includes(id) - ); - return ( - - ); - }) - )} -
+ + ))} + + +
); @@ -909,11 +899,13 @@ export function AlertRuleSourceFields({ ) : ( ( - {t("alertingPickResources")} - + {t("alertingPickHealthChecks")} + + 0 - : v.targetIds.length > 0; + : v.healthCheckIds.length > 0; } return Boolean(v.trigger); } @@ -300,8 +303,7 @@ function buildNodeData( type AlertRuleGraphEditorProps = { orgId: string; - ruleId: string; - createdAt: string; + alertRuleId?: number; initialValues: AlertRuleFormValues; isNew: boolean; }; @@ -310,13 +312,14 @@ const FORM_ID = "alert-rule-graph-form"; export default function AlertRuleGraphEditor({ orgId, - ruleId, - createdAt, + alertRuleId, initialValues, isNew }: AlertRuleGraphEditorProps) { const t = useTranslations(); const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isSaving, setIsSaving] = useState(false); const schema = useMemo(() => buildFormSchema(t), [t]); const form = useForm({ resolver: zodResolver(schema), @@ -335,8 +338,8 @@ export default function AlertRuleGraphEditor({ useWatch({ control: form.control, name: "sourceType" }) ?? "site"; const wSiteIds = useWatch({ control: form.control, name: "siteIds" }) ?? []; - const wTargetIds = - useWatch({ control: form.control, name: "targetIds" }) ?? []; + const wHealthCheckIds = + useWatch({ control: form.control, name: "healthCheckIds" }) ?? []; const wTrigger = useWatch({ control: form.control, name: "trigger" }) ?? "site_offline"; @@ -349,7 +352,7 @@ export default function AlertRuleGraphEditor({ enabled: wEnabled, sourceType: wSourceType, siteIds: wSiteIds, - targetIds: wTargetIds, + healthCheckIds: wHealthCheckIds, trigger: wTrigger, actions: wActions }), @@ -358,7 +361,7 @@ export default function AlertRuleGraphEditor({ wEnabled, wSourceType, wSiteIds, - wTargetIds, + wHealthCheckIds, wTrigger, wActions ] @@ -472,7 +475,7 @@ export default function AlertRuleGraphEditor({ if (!m) { return; } - const i = Number(m[1], 10); + const i = parseInt(m[1], 10); if (i >= wActions.length) { setSelectedStep( wActions.length > 0 @@ -486,12 +489,33 @@ export default function AlertRuleGraphEditor({ setSelectedStep(node.id); }, []); - const onSubmit = form.handleSubmit((values) => { - const next = formValuesToRule(values, ruleId, createdAt); - upsertRule(orgId, next); - toast({ title: t("alertingRuleSaved") }); - if (isNew) { - router.replace(`/${orgId}/settings/alerting/${ruleId}`); + const onSubmit = form.handleSubmit(async (values) => { + setIsSaving(true); + try { + const payload = formValuesToApiPayload(values); + if (isNew) { + const res = await api.put< + AxiosResponse + >(`/org/${orgId}/alert-rule`, payload); + toast({ title: t("alertingRuleSaved") }); + router.replace( + `/${orgId}/settings/alerting/${res.data.data.alertRuleId}` + ); + } else { + await api.post( + `/org/${orgId}/alert-rule/${alertRuleId}`, + payload + ); + toast({ title: t("alertingRuleSaved") }); + } + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setIsSaving(false); } }); @@ -565,8 +589,8 @@ export default function AlertRuleGraphEditor({ )} /> -
diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index c219f316b..b865a09ed 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -1,17 +1,27 @@ import type { Tag } from "@app/components/tags/tag-input"; -import { - type AlertRule, - type AlertTrigger, - isoNow, - type AlertAction as StoredAlertAction -} from "@app/lib/alertRulesLocalStorage"; import { z } from "zod"; +// --------------------------------------------------------------------------- +// Shared primitive schemas +// --------------------------------------------------------------------------- + export const tagSchema = z.object({ id: z.string(), text: z.string() }); +// --------------------------------------------------------------------------- +// Form-layer types +// NOTE: the form uses "health_check_unhealthy" internally; it maps to the +// backend's "health_check_not_healthy" at the API boundary. +// --------------------------------------------------------------------------- + +export type AlertTrigger = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_unhealthy"; + export type AlertRuleFormAction = | { type: "notify"; @@ -33,11 +43,86 @@ export type AlertRuleFormValues = { enabled: boolean; sourceType: "site" | "health_check"; siteIds: number[]; - targetIds: number[]; + healthCheckIds: number[]; trigger: AlertTrigger; actions: AlertRuleFormAction[]; }; +// --------------------------------------------------------------------------- +// API boundary types +// --------------------------------------------------------------------------- + +export type AlertRuleApiPayload = { + name: string; + eventType: + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + enabled: boolean; + siteIds: number[]; + healthCheckIds: number[]; + userIds: string[]; + roleIds: string[]; + emails: string[]; + webhookActions: { + webhookUrl: string; + enabled: boolean; + config?: string; + }[]; +}; + +// Shape of what GET /org/:orgId/alert-rule/:alertRuleId returns +export type AlertRuleApiResponse = { + alertRuleId: number; + orgId: string; + name: string; + eventType: string; + enabled: boolean; + cooldownSeconds: number; + lastTriggeredAt: number | null; + createdAt: number; + updatedAt: number; + siteIds: number[]; + healthCheckIds: number[]; + recipients: { + recipientId: number; + userId: string | null; + roleId: string | null; + email: string | null; + }[]; + webhookActions: { + webhookActionId: number; + webhookUrl: string; + enabled: boolean; + lastSentAt: number | null; + }[]; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function triggerToEventType( + trigger: AlertTrigger +): AlertRuleApiPayload["eventType"] { + if (trigger === "health_check_unhealthy") { + return "health_check_not_healthy"; + } + return trigger as AlertRuleApiPayload["eventType"]; +} + +function eventTypeToTrigger(eventType: string): AlertTrigger { + if (eventType === "health_check_not_healthy") { + return "health_check_unhealthy"; + } + return eventType as AlertTrigger; +} + +// --------------------------------------------------------------------------- +// Zod form schema (for react-hook-form validation) +// --------------------------------------------------------------------------- + export function buildFormSchema(t: (k: string) => string) { return z .object({ @@ -45,7 +130,7 @@ export function buildFormSchema(t: (k: string) => string) { enabled: z.boolean(), sourceType: z.enum(["site", "health_check"]), siteIds: z.array(z.number()), - targetIds: z.array(z.number()), + healthCheckIds: z.array(z.number()), trigger: z.enum([ "site_online", "site_offline", @@ -97,18 +182,15 @@ export function buildFormSchema(t: (k: string) => string) { } if ( val.sourceType === "health_check" && - val.targetIds.length === 0 + val.healthCheckIds.length === 0 ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("alertingErrorPickHealthChecks"), - path: ["targetIds"] + path: ["healthCheckIds"] }); } - const siteTriggers: AlertTrigger[] = [ - "site_online", - "site_offline" - ]; + const siteTriggers: AlertTrigger[] = ["site_online", "site_offline"]; const hcTriggers: AlertTrigger[] = [ "health_check_healthy", "health_check_unhealthy" @@ -156,7 +238,6 @@ export function buildFormSchema(t: (k: string) => string) { } if (a.type === "webhook") { try { - // eslint-disable-next-line no-new new URL(a.url.trim()); } catch { ctx.addIssue({ @@ -170,13 +251,17 @@ export function buildFormSchema(t: (k: string) => string) { }); } +// --------------------------------------------------------------------------- +// defaultFormValues +// --------------------------------------------------------------------------- + export function defaultFormValues(): AlertRuleFormValues { return { name: "", enabled: true, sourceType: "site", siteIds: [], - targetIds: [], + healthCheckIds: [], trigger: "site_offline", actions: [ { @@ -189,95 +274,128 @@ export function defaultFormValues(): AlertRuleFormValues { }; } -export function ruleToFormValues(rule: AlertRule): AlertRuleFormValues { - const actions: AlertRuleFormAction[] = rule.actions.map( - (a: StoredAlertAction) => { - if (a.type === "notify") { - return { - type: "notify", - userIds: a.userIds.map(String), - roleIds: [...a.roleIds], - emailTags: a.emails.map((e) => ({ id: e, text: e })) - }; - } - if (a.type === "sms") { - return { - type: "sms", - phoneTags: a.phoneNumbers.map((p) => ({ id: p, text: p })) - }; - } - return { - type: "webhook", - url: a.url, - method: a.method, - headers: - a.headers.length > 0 - ? a.headers.map((h) => ({ ...h })) - : [{ key: "", value: "" }], - secret: a.secret ?? "" - }; - } - ); +// --------------------------------------------------------------------------- +// API response → form values +// --------------------------------------------------------------------------- + +export function apiResponseToFormValues( + rule: AlertRuleApiResponse +): AlertRuleFormValues { + const trigger = eventTypeToTrigger(rule.eventType); + const sourceType = rule.eventType.startsWith("site_") + ? "site" + : "health_check"; + + // Collect notify recipients into a single notify action (if any) + const userIds = rule.recipients + .filter((r) => r.userId != null) + .map((r) => r.userId!); + const roleIds = rule.recipients + .filter((r) => r.roleId != null) + .map((r) => parseInt(r.roleId!, 10)) + .filter((n) => !isNaN(n)); + const emailTags = rule.recipients + .filter((r) => r.email != null) + .map((r) => ({ id: r.email!, text: r.email! })); + + const actions: AlertRuleFormAction[] = []; + + if (userIds.length > 0 || roleIds.length > 0 || emailTags.length > 0) { + actions.push({ type: "notify", userIds, roleIds, emailTags }); + } + + // Each webhook action becomes its own form webhook action + for (const w of rule.webhookActions) { + actions.push({ + type: "webhook", + url: w.webhookUrl, + method: "POST", + headers: [{ key: "", value: "" }], + secret: "" + }); + } + + // Always ensure at least one action so the form is valid + if (actions.length === 0) { + actions.push({ + type: "notify", + userIds: [], + roleIds: [], + emailTags: [] + }); + } + return { name: rule.name, enabled: rule.enabled, - sourceType: rule.source.type, - siteIds: - rule.source.type === "site" ? [...rule.source.siteIds] : [], - targetIds: - rule.source.type === "health_check" - ? [...rule.source.targetIds] - : [], - trigger: rule.trigger, + sourceType, + siteIds: rule.siteIds, + healthCheckIds: rule.healthCheckIds, + trigger, actions }; } -export function formValuesToRule( - v: AlertRuleFormValues, - id: string, - createdAt: string -): AlertRule { - const source = - v.sourceType === "site" - ? { type: "site" as const, siteIds: v.siteIds } - : { - type: "health_check" as const, - targetIds: v.targetIds - }; - const actions = v.actions.map((a) => { - if (a.type === "notify") { - return { - type: "notify" as const, - userIds: a.userIds, - roleIds: a.roleIds, - emails: a.emailTags.map((tg) => tg.text.trim()).filter(Boolean) - }; - } - if (a.type === "sms") { - return { - type: "sms" as const, - phoneNumbers: a.phoneTags - .map((tg) => tg.text.trim()) +// --------------------------------------------------------------------------- +// Form values → API payload +// --------------------------------------------------------------------------- + +export function formValuesToApiPayload( + values: AlertRuleFormValues +): AlertRuleApiPayload { + const eventType = triggerToEventType(values.trigger); + + // Collect all notify-type actions and merge their recipient lists + const allUserIds: string[] = []; + const allRoleIds: string[] = []; + const allEmails: string[] = []; + + const webhookActions: AlertRuleApiPayload["webhookActions"] = []; + + for (const action of values.actions) { + if (action.type === "notify") { + allUserIds.push(...action.userIds); + allRoleIds.push(...action.roleIds.map(String)); + allEmails.push( + ...action.emailTags + .map((t) => t.text.trim()) .filter(Boolean) - }; + ); + } else if (action.type === "webhook") { + webhookActions.push({ + webhookUrl: action.url.trim(), + enabled: true, + // Encode any headers / secret as config JSON if present + ...(action.secret.trim() || + action.headers.some((h) => h.key.trim()) + ? { + config: JSON.stringify({ + secret: action.secret.trim() || undefined, + headers: action.headers.filter( + (h) => h.key.trim() + ) + }) + } + : {}) + }); } - return { - type: "webhook" as const, - url: a.url.trim(), - method: a.method, - headers: a.headers.filter((h) => h.key.trim() || h.value.trim()), - secret: a.secret.trim() || undefined - }; - }); + // sms is not supported by the backend; silently skip + } + + // Deduplicate + const uniqueUserIds = [...new Set(allUserIds)]; + const uniqueRoleIds = [...new Set(allRoleIds)]; + const uniqueEmails = [...new Set(allEmails)]; + return { - id, - name: v.name.trim(), - enabled: v.enabled, - createdAt, - updatedAt: isoNow(), - source, - trigger: v.trigger, - actions + name: values.name.trim(), + eventType, + enabled: values.enabled, + siteIds: values.siteIds, + healthCheckIds: values.healthCheckIds, + userIds: uniqueUserIds, + roleIds: uniqueRoleIds, + emails: uniqueEmails, + webhookActions }; -} +} \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index d7822d6cf..17948d63a 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -4,9 +4,11 @@ import type { ListClientsResponse } from "@server/routers/client"; import type { ListDomainsResponse } from "@server/routers/domain"; import type { GetResourceWhitelistResponse, + ListHealthChecksResponse, ListResourceNamesResponse, ListResourcesResponse } from "@server/routers/resource"; +import type { ListAlertRulesResponse } from "@server/private/routers/alertRule"; import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { @@ -230,6 +232,38 @@ export const orgQueries = { return res.data.data.resources; } + }), + + healthChecks: ({ + orgId, + perPage = 10_000 + }: { + orgId: string; + perPage?: number; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "HEALTH_CHECKS", { perPage }] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + limit: perPage.toString(), + offset: "0" + }); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/health-checks?${sp.toString()}`, { signal }); + return res.data.data.healthChecks; + } + }), + + alertRules: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "ALERT_RULES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/alert-rules`, { signal }); + return res.data.data.alertRules; + } }) };