diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 72bcda76f..237238910 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -142,6 +142,7 @@ export async function updateProxyResources( .values({ name: `${targetData.hostname}:${targetData.port}`, targetId: newTarget.targetId, + orgId: orgId, hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, hcScheme: healthcheckData?.scheme, diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 7a6c11766..9d40817fb 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -21,7 +21,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -39,7 +39,17 @@ const querySchema = z.strictObject({ .optional() .default("0") .transform(Number) - .pipe(z.number().int().nonnegative()) + .pipe(z.number().int().nonnegative()), + siteId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()), + resourceId: z + .string() + .optional() + .transform((v) => (v !== undefined ? Number(v) : undefined)) + .pipe(z.number().int().positive().optional()) }); export type ListAlertRulesResponse = { @@ -102,12 +112,66 @@ export async function listAlertRules( ) ); } - const { limit, offset } = parsedQuery.data; + const { limit, offset, siteId, resourceId } = parsedQuery.data; + + // Resolve siteId filter → matching alertRuleIds + let siteFilterRuleIds: number[] | null = null; + if (siteId !== undefined) { + const rows = await db + .select({ alertRuleId: alertSites.alertRuleId }) + .from(alertSites) + .where(eq(alertSites.siteId, siteId)); + siteFilterRuleIds = rows.map((r) => r.alertRuleId); + if (siteFilterRuleIds.length === 0) { + return response(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 + let resourceFilterRuleIds: number[] | null = null; + if (resourceId !== undefined) { + const rows = await db + .select({ alertRuleId: alertResources.alertRuleId }) + .from(alertResources) + .where(eq(alertResources.resourceId, resourceId)); + resourceFilterRuleIds = rows.map((r) => r.alertRuleId); + if (resourceFilterRuleIds.length === 0) { + return response(res, { + data: { + alertRules: [], + pagination: { total: 0, limit, offset } + }, + success: true, + error: false, + message: "Alert rules retrieved successfully", + status: HttpCode.OK + }); + } + } + + const whereClause = and( + eq(alertRules.orgId, orgId), + siteFilterRuleIds !== null + ? inArray(alertRules.alertRuleId, siteFilterRuleIds) + : undefined, + resourceFilterRuleIds !== null + ? inArray(alertRules.alertRuleId, resourceFilterRuleIds) + : undefined + ); const list = await db .select() .from(alertRules) - .where(eq(alertRules.orgId, orgId)) + .where(whereClause) .orderBy(sql`${alertRules.createdAt} DESC`) .limit(limit) .offset(offset); @@ -115,7 +179,7 @@ export async function listAlertRules( const [{ count }] = await db .select({ count: sql`count(*)` }) .from(alertRules) - .where(eq(alertRules.orgId, orgId)); + .where(whereClause); // Batch-fetch site and health-check associations for all returned rules // in two queries rather than N+1 individual lookups. diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 5f47e1938..be94fb10a 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -62,7 +62,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import UptimeBar from "@app/components/UptimeBar"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; type MaintenanceSectionFormProps = { resource: GetResourceResponse; @@ -579,19 +579,12 @@ export default function GeneralForm() { return ( <> - - - Uptime - - Site availability over the last 90 days. - - - - {resource?.resourceId && ( - - )} - - + {resource?.resourceId && resource?.orgId && ( + + )} diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 3527e41cb..8797455ef 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -1,6 +1,6 @@ "use client"; -import UptimeBar from "@app/components/UptimeBar"; +import UptimeAlertSection from "@app/components/UptimeAlertSection"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -113,19 +113,12 @@ export default function GeneralPage() { return ( - - - Uptime - - Site availability over the last 90 days. - - - - {site?.siteId && ( - - )} - - + {site?.siteId && site?.orgId && ( + + )} diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx new file mode 100644 index 000000000..c7f0cc184 --- /dev/null +++ b/src/components/UptimeAlertSection.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { BellPlus, BellRing } from "lucide-react"; +import { + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody +} from "@app/components/Settings"; +import UptimeBar from "@app/components/UptimeBar"; +import { Button } from "@app/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { TagInput, type Tag } from "@app/components/tags/tag-input"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { orgQueries } from "@app/lib/queries"; + +interface UptimeAlertSectionProps { + orgId: string; + siteId?: number; + resourceId?: number; + days?: number; +} + +export default function UptimeAlertSection({ + orgId, + siteId, + resourceId, + days = 90 +}: UptimeAlertSectionProps) { + const api = createApiClient(useEnvContext()); + const queryClient = useQueryClient(); + + const [open, setOpen] = useState(false); + const [name, setName] = useState("Uptime Alert"); + const [userTags, setUserTags] = useState([]); + const [roleTags, setRoleTags] = useState([]); + const [emailTags, setEmailTags] = useState([]); + const [activeUserTagIndex, setActiveUserTagIndex] = useState( + null + ); + const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( + null + ); + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + const [loading, setLoading] = useState(false); + + const { data: alertRules, isLoading: alertRulesLoading } = useQuery( + orgQueries.alertRulesForSource({ orgId, siteId, resourceId }) + ); + + const { data: orgUsers = [] } = useQuery(orgQueries.users({ orgId })); + const { data: orgRoles = [] } = useQuery(orgQueries.roles({ orgId })); + + const allUsers = useMemo( + () => + orgUsers.map((u) => ({ + id: String(u.id), + text: getUserDisplayName({ + email: u.email, + name: u.name, + username: u.username + }) + })), + [orgUsers] + ); + + const allRoles = useMemo( + () => + orgRoles + .map((r) => ({ id: String(r.roleId), text: r.name })) + .filter((r) => r.text !== "Admin"), + [orgRoles] + ); + + const hasRules = (alertRules?.length ?? 0) > 0; + + async function handleSubmit() { + if ( + userTags.length === 0 && + roleTags.length === 0 && + emailTags.length === 0 + ) { + toast({ + variant: "destructive", + title: "No recipients", + description: + "Please add at least one user, role, or email to notify." + }); + return; + } + + setLoading(true); + try { + await api.put(`/org/${orgId}/alert-rule`, { + name, + eventType: siteId ? "site_toggle" : "resource_toggle", + enabled: true, + cooldownSeconds: 300, + siteIds: siteId ? [siteId] : [], + healthCheckIds: [], + resourceIds: resourceId ? [resourceId] : [], + userIds: userTags.map((tag) => tag.id), + roleIds: roleTags.map((tag) => Number(tag.id)), + emails: emailTags.map((tag) => tag.text), + webhookActions: [] + }); + + toast({ + title: "Alert created", + description: + "You will be notified when this changes status." + }); + + setOpen(false); + setName("Uptime Alert"); + setUserTags([]); + setRoleTags([]); + setEmailTags([]); + + queryClient.invalidateQueries({ + queryKey: orgQueries.alertRulesForSource({ + orgId, + siteId, + resourceId + }).queryKey + }); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to create alert", + description: formatAxiosError(e, "An error occurred.") + }); + } + setLoading(false); + } + + const alertButton = alertRulesLoading ? null : hasRules ? ( + + ) : ( + + ); + + return ( + <> + + +
+
+ Uptime + + Site availability over the last {days} days. + +
+ {alertButton} +
+
+ + + +
+ + + + + Create Email Alert + + Get notified by email when this{" "} + {siteId ? "site" : "resource"} goes offline or + comes back online. + + + +
+
+ + setName(e.target.value)} + placeholder="Alert name" + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(userTags) + : newTags; + setUserTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allUsers} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(roleTags) + : newTags; + setRoleTags(next as Tag[]); + }} + enableAutocomplete + autocompleteOptions={allRoles} + restrictTagsToAutocompleteOptions + allowDuplicates={false} + sortTags + /> +
+
+ + { + const next = + typeof newTags === "function" + ? newTags(emailTags) + : newTags; + setEmailTags(next as Tag[]); + }} + allowDuplicates={false} + sortTags + validateTag={(tag) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag) + } + delimiterList={[",", "Enter"]} + /> +
+
+
+ + + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e664009a4..25f96602d 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -267,6 +267,28 @@ export const orgQueries = { } }), + alertRulesForSource: ({ + orgId, + siteId, + resourceId + }: { + orgId: string; + siteId?: number; + resourceId?: number; + }) => + queryOptions({ + queryKey: ["ORG", orgId, "ALERT_RULES", { siteId, resourceId }] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams(); + if (siteId != null) sp.set("siteId", String(siteId)); + if (resourceId != null) sp.set("resourceId", String(resourceId)); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/alert-rules?${sp.toString()}`, { signal }); + return res.data.data.alertRules; + } + }), + standaloneHealthChecks: ({ orgId }: { orgId: string }) => queryOptions({ queryKey: ["ORG", orgId, "STANDALONE_HEALTH_CHECKS"] as const,