🚧 wip: test alert rule

This commit is contained in:
Fred KISSIE
2026-08-07 19:05:28 +02:00
parent e91c344e64
commit 6689a8d93e
6 changed files with 163 additions and 26 deletions
+4
View File
@@ -1804,6 +1804,10 @@
"alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.", "alertingRulesBannerDescription": "Each rule ties together what to watch (a site, health check, or resource), when to fire (for example offline or unhealthy), and how to notify your team via email, webhooks, or integrations. Use this list to create, enable, and manage those rules.",
"alertingHealthChecksBannerTitle": "Monitor Health & Resources", "alertingHealthChecksBannerTitle": "Monitor Health & Resources",
"alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.", "alertingHealthChecksBannerDescription": "Health checks are HTTP or TCP monitors you define once. You can then use them as sources in alert rules so you get notified when a target becomes healthy or unhealthy. Health checks on resources also appear here.",
"alertingTestRule": "Test Alert Rule",
"alertingNoActionsTitle": "No actions configured",
"alertingNoActionsSaveDescription": "Add at least one action so this rule can notify someone when it fires.",
"alertingNoActionsTestDescription": "Add at least one action before you can test this rule.",
"standaloneHcTableTitle": "Health Checks", "standaloneHcTableTitle": "Health Checks",
"standaloneHcSearchPlaceholder": "Search health checks…", "standaloneHcSearchPlaceholder": "Search health checks…",
"standaloneHcAddButton": "Create Health Check", "standaloneHcAddButton": "Create Health Check",
+1
View File
@@ -151,6 +151,7 @@ export enum ActionsEnum {
createAlertRule = "createAlertRule", createAlertRule = "createAlertRule",
updateAlertRule = "updateAlertRule", updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule", deleteAlertRule = "deleteAlertRule",
testAlertRule = "testAlertRule",
listAlertRules = "listAlertRules", listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels", listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel", createOrgLabel = "createOrgLabel",
@@ -16,3 +16,4 @@ export * from "./updateAlertRule";
export * from "./deleteAlertRule"; export * from "./deleteAlertRule";
export * from "./listAlertRules"; export * from "./listAlertRules";
export * from "./getAlertRule"; export * from "./getAlertRule";
export * from "./testSiteAlertRule";
@@ -0,0 +1,73 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import {
alertRules,
alertSites,
alertHealthChecks,
alertResources
} 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 { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
import { ListAlertRulesResponse } from "@server/routers/alertRule/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const querySchema = z.strictObject({
event: z.enum(["site_offline", "site_online", "site_toggle"])
});
export async function testSiteAlertRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { event } = parsedQuery.data;
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+8
View File
@@ -808,6 +808,14 @@ authenticated.get(
alertRule.listAlertRules alertRule.listAlertRules
); );
authenticated.get(
"/org/:orgId/test-site-alert-rule/:alertRuleId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.testAlertRule),
alertRule.testSiteAlertRule
);
authenticated.get( authenticated.get(
"/org/:orgId/alert-rule/:alertRuleId", "/org/:orgId/alert-rule/:alertRuleId",
verifyValidLicense, verifyValidLicense,
@@ -6,7 +6,9 @@ import {
AlertRuleSourceFields, AlertRuleSourceFields,
AlertRuleTriggerFields AlertRuleTriggerFields
} from "@app/components/alert-rule-editor/AlertRuleFields"; } from "@app/components/alert-rule-editor/AlertRuleFields";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SettingsContainer } from "@app/components/Settings"; import { SettingsContainer } from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Card, CardContent } from "@app/components/ui/card"; import { Card, CardContent } from "@app/components/ui/card";
import { import {
@@ -19,6 +21,7 @@ import {
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { import {
buildFormSchema, buildFormSchema,
@@ -27,19 +30,15 @@ import {
type AlertRuleFormValues type AlertRuleFormValues
} from "@app/lib/alertRuleForm"; } from "@app/lib/alertRuleForm";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types"; import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { zodResolver } from "@hookform/resolvers/zod"; import { Cog, Flag, Zap, ZapIcon } from "lucide-react";
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, type ReactNode } from "react";
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { useRouter } from "next/navigation";
import { SwitchInput } from "@app/components/SwitchInput"; import { useActionState, useMemo, useTransition, type ReactNode } from "react";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useFieldArray, useForm, type Resolver } from "react-hook-form";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
const FORM_ID = "alert-rule-form"; const FORM_ID = "alert-rule-form";
@@ -115,7 +114,6 @@ export default function AlertRuleGraphEditor({
const t = useTranslations(); const t = useTranslations();
const router = useRouter(); const router = useRouter();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isSaving, setIsSaving] = useState(false);
const schema = useMemo(() => buildFormSchema(t), [t]); const schema = useMemo(() => buildFormSchema(t), [t]);
const form = useForm<AlertRuleFormValues>({ const form = useForm<AlertRuleFormValues>({
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>, resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
@@ -127,8 +125,22 @@ export default function AlertRuleGraphEditor({
name: "actions" name: "actions"
}); });
const onSubmit = form.handleSubmit(async (values) => { const saveAlert = async () => {
setIsSaving(true); const isValid = await form.trigger();
if (!isValid) {
const values = form.getValues();
if (values.actions.length === 0) {
toast({
variant: "warning",
title: t("alertingNoActionsTitle"),
description: t("alertingNoActionsSaveDescription")
});
}
return;
}
const values = form.getValues();
try { try {
const payload = formValuesToApiPayload(values); const payload = formValuesToApiPayload(values);
if (isNew) { if (isNew) {
@@ -158,14 +170,37 @@ export default function AlertRuleGraphEditor({
description: formatAxiosError(e), description: formatAxiosError(e),
variant: "destructive" variant: "destructive"
}); });
} finally {
setIsSaving(false);
} }
// const submit = form.handleSubmit(async (values) => {
// });
// await submit();
};
const testAlert = async () => {
const isValid = await form.trigger();
if (!isValid) {
const values = form.getValues();
if (values.actions.length === 0) {
toast({
variant: "warning",
title: t("alertingNoActionsTitle"),
description: t("alertingNoActionsTestDescription")
}); });
}
return;
}
const values = form.getValues();
};
const [, formAction, isSaving] = useActionState(saveAlert, null);
const [isTestingAlert, startTransition] = useTransition();
return ( return (
<Form {...form}> <Form {...form}>
<form id={FORM_ID} onSubmit={onSubmit}> <form id={FORM_ID} action={formAction}>
<SettingsContainer> <SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} /> <PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start"> <div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
@@ -263,6 +298,7 @@ export default function AlertRuleGraphEditor({
</FormItem> </FormItem>
)} )}
/> />
<div className="flex flex-col items-center w-full gap-3">
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
@@ -271,6 +307,20 @@ export default function AlertRuleGraphEditor({
> >
{t("save")} {t("save")}
</Button> </Button>
<Button
type="button"
variant="outline"
className="w-full gap-1.5"
onClick={() =>
startTransition(testAlert)
}
loading={isTestingAlert}
>
{t("alertingTestRule")}
<ZapIcon className="size-3.5 flex-none" />
</Button>
</div>
</fieldset> </fieldset>
</CardContent> </CardContent>
</Card> </Card>