Add inline creation

This commit is contained in:
Owen
2026-04-20 18:02:14 -07:00
parent f38069623b
commit 1a36475afa
6 changed files with 406 additions and 33 deletions

View File

@@ -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,

View File

@@ -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<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
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<ListAlertRulesResponse>(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<number>`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.

View File

@@ -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 (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{resource?.resourceId && (
<UptimeBar resourceId={resource.resourceId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
{resource?.resourceId && resource?.orgId && (
<UptimeAlertSection
orgId={resource.orgId}
resourceId={resource.resourceId}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -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 (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last 90 days.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{site?.siteId && (
<UptimeBar siteId={site.siteId} days={90} />
)}
</SettingsSectionBody>
</SettingsSection>
{site?.siteId && site?.orgId && (
<UptimeAlertSection
orgId={site.orgId}
siteId={site.siteId}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View File

@@ -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<Tag[]>([]);
const [roleTags, setRoleTags] = useState<Tag[]>([]);
const [emailTags, setEmailTags] = useState<Tag[]>([]);
const [activeUserTagIndex, setActiveUserTagIndex] = useState<number | null>(
null
);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
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 ? (
<Button variant="outline" size="sm" asChild>
<Link href={`/${orgId}/settings/alerting`}>
<BellRing className="size-4 mr-2" />
View Alerts
</Link>
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<BellPlus className="size-4 mr-2" />
Add Alert
</Button>
);
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<div className="flex justify-between items-start">
<div>
<SettingsSectionTitle>Uptime</SettingsSectionTitle>
<SettingsSectionDescription>
Site availability over the last {days} days.
</SettingsSectionDescription>
</div>
{alertButton}
</div>
</SettingsSectionHeader>
<SettingsSectionBody>
<UptimeBar
siteId={siteId}
resourceId={resourceId}
days={days}
/>
</SettingsSectionBody>
</SettingsSection>
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Email Alert</CredenzaTitle>
<CredenzaDescription>
Get notified by email when this{" "}
{siteId ? "site" : "resource"} goes offline or
comes back online.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="alert-name">Name</Label>
<Input
id="alert-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Alert name"
/>
</div>
<div className="space-y-2">
<Label>Notify Users</Label>
<TagInput
activeTagIndex={activeUserTagIndex}
setActiveTagIndex={setActiveUserTagIndex}
placeholder="Select users..."
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
setUserTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allUsers}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/>
</div>
<div className="space-y-2">
<Label>Notify Roles</Label>
<TagInput
activeTagIndex={activeRoleTagIndex}
setActiveTagIndex={setActiveRoleTagIndex}
placeholder="Select roles..."
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
setRoleTags(next as Tag[]);
}}
enableAutocomplete
autocompleteOptions={allRoles}
restrictTagsToAutocompleteOptions
allowDuplicates={false}
sortTags
/>
</div>
<div className="space-y-2">
<Label>Additional Emails</Label>
<TagInput
activeTagIndex={activeEmailTagIndex}
setActiveTagIndex={setActiveEmailTagIndex}
placeholder="Enter email addresses..."
size="sm"
tags={emailTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(emailTags)
: newTags;
setEmailTags(next as Tag[]);
}}
allowDuplicates={false}
sortTags
validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
}
delimiterList={[",", "Enter"]}
/>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button
onClick={handleSubmit}
loading={loading}
disabled={loading}
>
Create Alert
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -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<ListAlertRulesResponse>
>(`/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,