fix site and resource filters on alert

This commit is contained in:
miloschwartz
2026-04-21 17:22:50 -07:00
parent 22a6dabeb2
commit 0434b1a656
4 changed files with 193 additions and 90 deletions

View File

@@ -14,14 +14,19 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { alertRules, alertSites, alertHealthChecks, alertResources } from "@server/db"; import {
alertRules,
alertSites,
alertHealthChecks,
alertResources
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, desc, eq, inArray, like, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -51,11 +56,34 @@ const querySchema = z.strictObject({
.optional() .optional()
.transform((v) => (v !== undefined ? Number(v) : undefined)) .transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().int().positive().optional()), .pipe(z.number().int().positive().optional()),
healthCheckId: z
.string()
.optional()
.transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().int().positive().optional()),
sort_by: z.enum(["name", "last_triggered_at"]).optional(), sort_by: z.enum(["name", "last_triggered_at"]).optional(),
order: z.enum(["asc", "desc"]).optional().default("asc"), order: z.enum(["asc", "desc"]).optional().default("asc"),
enabled: z.enum(["true", "false"]).optional() enabled: z.enum(["true", "false"]).optional()
}); });
const SITE_ALERT_EVENT_TYPES = [
"site_online",
"site_offline",
"site_toggle"
] as const;
const RESOURCE_ALERT_EVENT_TYPES = [
"resource_healthy",
"resource_unhealthy",
"resource_toggle"
] as const;
const HEALTH_CHECK_ALERT_EVENT_TYPES = [
"health_check_healthy",
"health_check_unhealthy",
"health_check_toggle"
] as const;
export type ListAlertRulesResponse = { export type ListAlertRulesResponse = {
alertRules: { alertRules: {
alertRuleId: number; alertRuleId: number;
@@ -122,66 +150,110 @@ export async function listAlertRules(
query, query,
siteId, siteId,
resourceId, resourceId,
healthCheckId,
sort_by, sort_by,
order, order,
enabled: enabledFilter enabled: enabledFilter
} = parsedQuery.data; } = parsedQuery.data;
// Resolve siteId filter → matching alertRuleIds const explicitSiteRuleIds: number[] =
let siteFilterRuleIds: number[] | null = null; siteId !== undefined
if (siteId !== undefined) { ? (
const rows = await db await db
.select({ alertRuleId: alertSites.alertRuleId }) .select({ alertRuleId: alertSites.alertRuleId })
.from(alertSites) .from(alertSites)
.where(eq(alertSites.siteId, siteId)); .where(eq(alertSites.siteId, siteId))
siteFilterRuleIds = rows.map((r) => r.alertRuleId); ).map((r) => r.alertRuleId)
if (siteFilterRuleIds.length === 0) { : [];
return response<ListAlertRulesResponse>(res, {
data: {
alertRules: [],
pagination: { total: 0, limit, offset }
},
success: true,
error: false,
message: "Alert rules retrieved successfully",
status: HttpCode.OK
});
}
}
// Resolve resourceId filter → matching alertRuleIds const explicitResourceRuleIds: number[] =
let resourceFilterRuleIds: number[] | null = null; resourceId !== undefined
if (resourceId !== undefined) { ? (
const rows = await db await db
.select({ alertRuleId: alertResources.alertRuleId }) .select({
.from(alertResources) alertRuleId: alertResources.alertRuleId
.where(eq(alertResources.resourceId, resourceId)); })
resourceFilterRuleIds = rows.map((r) => r.alertRuleId); .from(alertResources)
if (resourceFilterRuleIds.length === 0) { .where(eq(alertResources.resourceId, resourceId))
return response<ListAlertRulesResponse>(res, { ).map((r) => r.alertRuleId)
data: { : [];
alertRules: [],
pagination: { total: 0, limit, offset } const explicitHealthCheckRuleIds: number[] =
}, healthCheckId !== undefined
success: true, ? (
error: false, await db
message: "Alert rules retrieved successfully", .select({
status: HttpCode.OK alertRuleId: alertHealthChecks.alertRuleId
}); })
} .from(alertHealthChecks)
} .where(
eq(alertHealthChecks.healthCheckId, healthCheckId)
)
).map((r) => r.alertRuleId)
: [];
const allSitesWildcardClause = and(
eq(alertRules.allSites, true),
inArray(alertRules.eventType, SITE_ALERT_EVENT_TYPES)
);
const siteScopeClause =
siteId !== undefined
? explicitSiteRuleIds.length > 0
? or(
allSitesWildcardClause,
inArray(alertRules.alertRuleId, explicitSiteRuleIds)
)
: allSitesWildcardClause
: undefined;
const allResourcesWildcardClause = and(
eq(alertRules.allResources, true),
inArray(alertRules.eventType, RESOURCE_ALERT_EVENT_TYPES)
);
const resourceScopeClause =
resourceId !== undefined
? explicitResourceRuleIds.length > 0
? or(
allResourcesWildcardClause,
inArray(
alertRules.alertRuleId,
explicitResourceRuleIds
)
)
: allResourcesWildcardClause
: undefined;
const allHealthChecksWildcardClause = and(
eq(alertRules.allHealthChecks, true),
inArray(alertRules.eventType, HEALTH_CHECK_ALERT_EVENT_TYPES)
);
const healthCheckScopeClause =
healthCheckId !== undefined
? explicitHealthCheckRuleIds.length > 0
? or(
allHealthChecksWildcardClause,
inArray(
alertRules.alertRuleId,
explicitHealthCheckRuleIds
)
)
: allHealthChecksWildcardClause
: undefined;
const whereClause = and( const whereClause = and(
eq(alertRules.orgId, orgId), eq(alertRules.orgId, orgId),
query query
? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`) ? like(
: undefined, sql`LOWER(${alertRules.name})`,
siteFilterRuleIds !== null `%${query.toLowerCase()}%`
? inArray(alertRules.alertRuleId, siteFilterRuleIds) )
: undefined,
resourceFilterRuleIds !== null
? inArray(alertRules.alertRuleId, resourceFilterRuleIds)
: undefined, : undefined,
siteScopeClause,
resourceScopeClause,
healthCheckScopeClause,
enabledFilter !== undefined enabledFilter !== undefined
? eq(alertRules.enabled, enabledFilter === "true") ? eq(alertRules.enabled, enabledFilter === "true")
: undefined : undefined
@@ -228,9 +300,7 @@ export async function listAlertRules(
? await db ? await db
.select() .select()
.from(alertHealthChecks) .from(alertHealthChecks)
.where( .where(inArray(alertHealthChecks.alertRuleId, ruleIds))
inArray(alertHealthChecks.alertRuleId, ruleIds)
)
: []; : [];
const resourceRows = const resourceRows =
@@ -297,4 +367,4 @@ export async function listAlertRules(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
} }
} }

View File

@@ -46,6 +46,9 @@ export default async function AlertingRulesPage(props: AlertingRulesPageProps) {
const resourceId = parsePositiveInt( const resourceId = parsePositiveInt(
searchParams.get("resourceId") ?? undefined searchParams.get("resourceId") ?? undefined
); );
const healthCheckId = parsePositiveInt(
searchParams.get("healthCheckId") ?? undefined
);
const apiSp = new URLSearchParams(); const apiSp = new URLSearchParams();
apiSp.set("limit", String(pageSize)); apiSp.set("limit", String(pageSize));
@@ -53,6 +56,8 @@ export default async function AlertingRulesPage(props: AlertingRulesPageProps) {
if (query) apiSp.set("query", query); if (query) apiSp.set("query", query);
if (siteId != null) apiSp.set("siteId", String(siteId)); if (siteId != null) apiSp.set("siteId", String(siteId));
if (resourceId != null) apiSp.set("resourceId", String(resourceId)); if (resourceId != null) apiSp.set("resourceId", String(resourceId));
if (healthCheckId != null)
apiSp.set("healthCheckId", String(healthCheckId));
if (sortBy) { if (sortBy) {
apiSp.set("sort_by", sortBy); apiSp.set("sort_by", sortBy);
if (order) apiSp.set("order", order); if (order) apiSp.set("order", order);

View File

@@ -51,7 +51,9 @@ export default function UptimeAlertSection({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [name, setName] = useState(`${siteId ? "Site" : "Resource"} ${startingName} Alert`); const [name, setName] = useState(
`${siteId ? "Site" : "Resource"} ${startingName} Alert`
);
const [userTags, setUserTags] = useState<Tag[]>([]); const [userTags, setUserTags] = useState<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]); const [roleTags, setRoleTags] = useState<Tag[]>([]);
const [emailTags, setEmailTags] = useState<Tag[]>([]); const [emailTags, setEmailTags] = useState<Tag[]>([]);
@@ -129,8 +131,7 @@ export default function UptimeAlertSection({
toast({ toast({
title: "Alert created", title: "Alert created",
description: description: "You will be notified when this changes status."
"You will be notified when this changes status."
}); });
setOpen(false); setOpen(false);
@@ -156,11 +157,17 @@ export default function UptimeAlertSection({
setLoading(false); setLoading(false);
} }
const rulesListSearch = new URLSearchParams();
if (siteId != null) rulesListSearch.set("siteId", String(siteId));
if (resourceId != null)
rulesListSearch.set("resourceId", String(resourceId));
const rulesListHref = `/${orgId}/settings/alerting/rules${
rulesListSearch.toString() ? `?${rulesListSearch}` : ""
}`;
const alertButton = alertRulesLoading ? null : hasRules ? ( const alertButton = alertRulesLoading ? null : hasRules ? (
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link <Link href={rulesListHref}>
href={`/${orgId}/settings/alerting/rules?siteId=${siteId}&resourceId=${resourceId}`}
>
<BellRing className="size-4 mr-2" /> <BellRing className="size-4 mr-2" />
View Alerts View Alerts
</Link> </Link>
@@ -201,8 +208,8 @@ export default function UptimeAlertSection({
<CredenzaTitle>Create Email Alert</CredenzaTitle> <CredenzaTitle>Create Email Alert</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
Get notified by email when this{" "} Get notified by email when this{" "}
{siteId ? "site" : "resource"} goes offline or {siteId ? "site" : "resource"} goes offline or comes
comes back online. back online.
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>

View File

@@ -1,7 +1,10 @@
import { build } from "@server/build"; import { build } from "@server/build";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs"; import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
import type { ListClientsResponse } from "@server/routers/client"; import type { ListClientsResponse } from "@server/routers/client";
import type { ListDomainsResponse, GetDNSRecordsResponse } from "@server/routers/domain"; import type {
ListDomainsResponse,
GetDNSRecordsResponse
} from "@server/routers/domain";
import type { GetDomainResponse } from "@server/routers/domain/getDomain"; import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import type { import type {
GetResourceWhitelistResponse, GetResourceWhitelistResponse,
@@ -263,6 +266,7 @@ export const orgQueries = {
query, query,
siteId, siteId,
resourceId, resourceId,
healthCheckId,
sortBy, sortBy,
order, order,
enabled enabled
@@ -273,6 +277,7 @@ export const orgQueries = {
query?: string; query?: string;
siteId?: number; siteId?: number;
resourceId?: number; resourceId?: number;
healthCheckId?: number;
sortBy?: string; sortBy?: string;
order?: string; order?: string;
enabled?: string; enabled?: string;
@@ -282,7 +287,17 @@ export const orgQueries = {
"ORG", "ORG",
orgId, orgId,
"ALERT_RULES", "ALERT_RULES",
{ limit, offset, query, siteId, resourceId, sortBy, order, enabled } {
limit,
offset,
query,
siteId,
resourceId,
healthCheckId,
sortBy,
order,
enabled
}
] as const, ] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
@@ -290,7 +305,10 @@ export const orgQueries = {
sp.set("offset", String(offset)); sp.set("offset", String(offset));
if (query) sp.set("query", query); if (query) sp.set("query", query);
if (siteId != null) sp.set("siteId", String(siteId)); if (siteId != null) sp.set("siteId", String(siteId));
if (resourceId != null) sp.set("resourceId", String(resourceId)); if (resourceId != null)
sp.set("resourceId", String(resourceId));
if (healthCheckId != null)
sp.set("healthCheckId", String(healthCheckId));
if (sortBy) { if (sortBy) {
sp.set("sort_by", sortBy); sp.set("sort_by", sortBy);
if (order) sp.set("order", order); if (order) sp.set("order", order);
@@ -309,18 +327,29 @@ export const orgQueries = {
alertRulesForSource: ({ alertRulesForSource: ({
orgId, orgId,
siteId, siteId,
resourceId resourceId,
healthCheckId
}: { }: {
orgId: string; orgId: string;
siteId?: number; siteId?: number;
resourceId?: number; resourceId?: number;
healthCheckId?: number;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "ALERT_RULES", { siteId, resourceId }] as const, queryKey: [
"ORG",
orgId,
"ALERT_RULES",
{ siteId, resourceId, healthCheckId }
] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (siteId != null) sp.set("siteId", String(siteId)); if (siteId != null && siteId !== undefined)
if (resourceId != null) sp.set("resourceId", String(resourceId)); sp.set("siteId", String(siteId));
if (resourceId != null && resourceId !== undefined)
sp.set("resourceId", String(resourceId));
if (healthCheckId != null && healthCheckId !== undefined)
sp.set("healthCheckId", String(healthCheckId));
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListAlertRulesResponse> AxiosResponse<ListAlertRulesResponse>
>(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal }); >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal });
@@ -340,7 +369,12 @@ export const orgQueries = {
query?: string; query?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS", { limit, offset, query }] as const, queryKey: [
"ORG",
orgId,
"STANDALONE_HEALTH_CHECKS",
{ limit, offset, query }
] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
sp.set("limit", String(limit)); sp.set("limit", String(limit));
@@ -417,7 +451,9 @@ export const orgQueries = {
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse> AxiosResponse<StatusHistoryResponse>
>(`/resource/${resourceId}/status-history?days=${days}`, { signal }); >(`/resource/${resourceId}/status-history?days=${days}`, {
signal
});
return res.data.data; return res.data.data;
} }
}), }),
@@ -692,13 +728,7 @@ export const approvalQueries = {
}; };
export const domainQueries = { export const domainQueries = {
getDomain: ({ getDomain: ({ orgId, domainId }: { orgId: string; domainId: string }) =>
orgId,
domainId
}: {
orgId: string;
domainId: string;
}) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "DOMAIN", domainId] as const, queryKey: ["ORG", orgId, "DOMAIN", domainId] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
@@ -709,13 +739,7 @@ export const domainQueries = {
}, },
refetchInterval: durationToMs(10, "seconds") refetchInterval: durationToMs(10, "seconds")
}), }),
getDNSRecords: ({ getDNSRecords: ({ orgId, domainId }: { orgId: string; domainId: string }) =>
orgId,
domainId
}: {
orgId: string;
domainId: string;
}) =>
queryOptions({ queryOptions({
queryKey: [ queryKey: [
"ORG", "ORG",
@@ -727,10 +751,7 @@ export const domainQueries = {
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<GetDNSRecordsResponse> AxiosResponse<GetDNSRecordsResponse>
>( >(`/org/${orgId}/domain/${domainId}/dns-records`, { signal });
`/org/${orgId}/domain/${domainId}/dns-records`,
{ signal }
);
return res.data.data; return res.data.data;
}, },
refetchInterval: durationToMs(10, "seconds") refetchInterval: durationToMs(10, "seconds")