Trying to use more consistant components

This commit is contained in:
Owen
2026-04-15 15:51:41 -07:00
parent 5e505224d0
commit 55595ec042
5 changed files with 180 additions and 352 deletions

View File

@@ -1382,16 +1382,13 @@
"alertingTriggerHcUnhealthy": "Health check unhealthy",
"alertingSectionActions": "Actions",
"alertingAddAction": "Add action",
"alertingActionNotify": "Notify",
"alertingActionSms": "SMS",
"alertingActionNotify": "Email",
"alertingActionWebhook": "Webhook",
"alertingActionType": "Action type",
"alertingNotifyUsers": "Users",
"alertingNotifyRoles": "Roles",
"alertingNotifyEmails": "Email addresses",
"alertingEmailPlaceholder": "Add email and press Enter",
"alertingSmsNumbers": "Phone numbers",
"alertingSmsPlaceholder": "Add number and press Enter",
"alertingWebhookMethod": "HTTP method",
"alertingWebhookSecret": "Signing secret (optional)",
"alertingWebhookSecretPlaceholder": "HMAC secret",
@@ -1416,8 +1413,6 @@
"alertingErrorTriggerSite": "Choose a site trigger",
"alertingErrorTriggerHealth": "Choose a health check trigger",
"alertingErrorNotifyRecipients": "Pick users, roles, or at least one email",
"alertingErrorSmsPhones": "Add at least one phone number",
"alertingErrorWebhookUrl": "Enter a valid webhook URL",
"alertingConfigureSource": "Configure Source",
"alertingConfigureTrigger": "Configure Trigger",
"alertingConfigureActions": "Configure Actions",

View File

@@ -30,7 +30,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { TagInput } from "@app/components/tags/tag-input";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import {
type AlertRuleFormAction,
@@ -46,9 +46,9 @@ import { useFormContext, useWatch } from "react-hook-form";
import { useDebounce } from "use-debounce";
export function DropdownAddAction({
onPick
onAdd
}: {
onPick: (type: "notify" | "sms" | "webhook") => void;
onAdd: (type: AlertRuleFormAction["type"]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
@@ -58,44 +58,32 @@ export function DropdownAddAction({
<Button type="button" variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" />
{t("alertingAddAction")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-2" align="end">
<div className="flex flex-col gap-1">
<Button
type="button"
variant="ghost"
className="justify-start"
onClick={() => {
onPick("notify");
setOpen(false);
}}
>
{t("alertingActionNotify")}
</Button>
<Button
type="button"
variant="ghost"
className="justify-start"
onClick={() => {
onPick("sms");
setOpen(false);
}}
>
{t("alertingActionSms")}
</Button>
<Button
type="button"
variant="ghost"
className="justify-start"
onClick={() => {
onPick("webhook");
setOpen(false);
}}
>
{t("alertingActionWebhook")}
</Button>
</div>
<PopoverContent className="p-0 w-48" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
onAdd("notify");
setOpen(false);
}}
>
{t("alertingActionNotify")}
</CommandItem>
<CommandItem
onSelect={() => {
onAdd("webhook");
setOpen(false);
}}
>
{t("alertingActionWebhook")}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
@@ -325,15 +313,10 @@ export function ActionBlock({
if (nt === "notify") {
form.setValue(`actions.${index}`, {
type: "notify",
userIds: [],
roleIds: [],
userTags: [],
roleTags: [],
emailTags: []
});
} else if (nt === "sms") {
form.setValue(`actions.${index}`, {
type: "sms",
phoneTags: []
});
} else {
form.setValue(`actions.${index}`, {
type: "webhook",
@@ -354,9 +337,6 @@ export function ActionBlock({
<SelectItem value="notify">
{t("alertingActionNotify")}
</SelectItem>
<SelectItem value="sms">
{t("alertingActionSms")}
</SelectItem>
<SelectItem value="webhook">
{t("alertingActionWebhook")}
</SelectItem>
@@ -373,9 +353,6 @@ export function ActionBlock({
form={form}
/>
)}
{type === "sms" && (
<SmsActionFields index={index} control={control} form={form} />
)}
{type === "webhook" && (
<WebhookActionFields
index={index}
@@ -399,41 +376,124 @@ function NotifyActionFields({
form: UseFormReturn<AlertRuleFormValues>;
}) {
const t = useTranslations();
const [emailActiveIdx, setEmailActiveIdx] = useState<number | null>(null);
const userIds = form.watch(`actions.${index}.userIds`) ?? [];
const roleIds = form.watch(`actions.${index}.roleIds`) ?? [];
const emailTags = form.watch(`actions.${index}.emailTags`) ?? [];
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
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 userTags = (form.watch(`actions.${index}.userTags`) ?? []) as Tag[];
const roleTags = (form.watch(`actions.${index}.roleTags`) ?? []) as Tag[];
const emailTags = (form.watch(`actions.${index}.emailTags`) ?? []) as Tag[];
return (
<div className="space-y-3 pt-1">
<FormItem>
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
<UserMultiSelect
orgId={orgId}
value={userIds}
onChange={(ids) =>
form.setValue(`actions.${index}.userIds`, ids)
}
/>
</FormItem>
<FormItem>
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
<RoleMultiSelect
orgId={orgId}
value={roleIds}
onChange={(ids) =>
form.setValue(`actions.${index}.roleIds`, ids)
}
/>
</FormItem>
<FormField
control={control}
name={`actions.${index}.userTags`}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyUsers")}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeUsersTagIndex}
setActiveTagIndex={setActiveUsersTagIndex}
placeholder={t("alertingSelectUsers")}
size="sm"
tags={userTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(userTags)
: newTags;
form.setValue(
`actions.${index}.userTags`,
next as Tag[]
);
}}
enableAutocomplete={true}
autocompleteOptions={allUsers}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`actions.${index}.roleTags`}
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyRoles")}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeRolesTagIndex}
setActiveTagIndex={setActiveRolesTagIndex}
placeholder={t("alertingSelectRoles")}
size="sm"
tags={roleTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(roleTags)
: newTags;
form.setValue(
`actions.${index}.roleTags`,
next as Tag[]
);
}}
enableAutocomplete={true}
autocompleteOptions={allRoles}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name={`actions.${index}.emailTags`}
render={() => (
<FormItem>
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("alertingNotifyEmails")}</FormLabel>
<FormControl>
<TagInput
{...field}
tags={emailTags}
setTags={(updater) => {
const next =
@@ -442,12 +502,18 @@ function NotifyActionFields({
: updater;
form.setValue(
`actions.${index}.emailTags`,
next
next as Tag[]
);
}}
activeTagIndex={emailActiveIdx}
setActiveTagIndex={setEmailActiveIdx}
placeholder={t("alertingEmailPlaceholder")}
size="sm"
allowDuplicates={false}
sortTags={true}
validateTag={(tag) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
}
delimiterList={[",", "Enter"]}
/>
</FormControl>
@@ -459,51 +525,6 @@ function NotifyActionFields({
);
}
function SmsActionFields({
index,
control,
form
}: {
index: number;
control: Control<AlertRuleFormValues>;
form: UseFormReturn<AlertRuleFormValues>;
}) {
const t = useTranslations();
const [phoneActiveIdx, setPhoneActiveIdx] = useState<number | null>(null);
const phoneTags = form.watch(`actions.${index}.phoneTags`) ?? [];
return (
<FormField
control={control}
name={`actions.${index}.phoneTags`}
render={() => (
<FormItem>
<FormLabel>{t("alertingSmsNumbers")}</FormLabel>
<FormControl>
<TagInput
tags={phoneTags}
setTags={(updater) => {
const next =
typeof updater === "function"
? updater(phoneTags)
: updater;
form.setValue(
`actions.${index}.phoneTags`,
next
);
}}
activeTagIndex={phoneActiveIdx}
setActiveTagIndex={setPhoneActiveIdx}
placeholder={t("alertingSmsPlaceholder")}
delimiterList={[",", "Enter"]}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
function WebhookActionFields({
index,
control,
@@ -663,160 +684,6 @@ function WebhookHeadersField({
);
}
function UserMultiSelect({
orgId,
value,
onChange
}: {
orgId: string;
value: string[];
onChange: (v: string[]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [debounced] = useDebounce(q, 150);
const { data: users = [] } = useQuery(orgQueries.users({ orgId }));
const shown = useMemo(() => {
const qq = debounced.trim().toLowerCase();
if (!qq) return users.slice(0, 200);
return users
.filter((u) => {
const label = getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
}).toLowerCase();
return (
label.includes(qq) ||
(u.email ?? "").toLowerCase().includes(qq)
);
})
.slice(0, 200);
}, [users, debounced]);
const toggle = (id: string) => {
if (value.includes(id)) {
onChange(value.filter((x) => x !== id));
} else {
onChange([...value, id]);
}
};
const summary =
value.length === 0
? t("alertingSelectUsers")
: t("alertingUsersSelected", { count: value.length });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("searchPlaceholder")}
value={q}
onValueChange={setQ}
/>
<CommandList>
<CommandEmpty>{t("noResults")}</CommandEmpty>
<CommandGroup>
{shown.map((u) => {
const uid = String(u.id);
return (
<CommandItem
key={uid}
value={uid}
onSelect={() => toggle(uid)}
className="cursor-pointer"
>
<Checkbox
checked={value.includes(uid)}
className="mr-2 pointer-events-none"
/>
{getUserDisplayName({
email: u.email,
name: u.name,
username: u.username
})}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function RoleMultiSelect({
orgId,
value,
onChange
}: {
orgId: string;
value: number[];
onChange: (v: number[]) => void;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const { data: roles = [] } = useQuery(orgQueries.roles({ orgId }));
const toggle = (id: number) => {
if (value.includes(id)) {
onChange(value.filter((x) => x !== id));
} else {
onChange([...value, id]);
}
};
const summary =
value.length === 0
? t("alertingSelectRoles")
: t("alertingRolesSelected", { count: value.length });
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">{summary}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command shouldFilter={false}>
<CommandList>
<CommandGroup>
{roles.map((r) => (
<CommandItem
key={r.roleId}
value={`role-${r.roleId}`}
onSelect={() => toggle(r.roleId)}
className="cursor-pointer"
>
<Checkbox
checked={value.includes(r.roleId)}
className="mr-2 pointer-events-none"
/>
{r.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function AlertRuleSourceFields({
orgId,
control
@@ -838,7 +705,8 @@ export function AlertRuleSourceFields({
<Select
value={field.value}
onValueChange={(v) => {
const next = v as AlertRuleFormValues["sourceType"];
const next =
v as AlertRuleFormValues["sourceType"];
field.onChange(next);
const curTrigger = getValues("trigger");
if (next === "site") {
@@ -970,4 +838,4 @@ export function AlertRuleTriggerFields({
)}
/>
);
}
}

View File

@@ -104,14 +104,12 @@ function summarizeTrigger(v: AlertRuleFormValues, t: AlertRuleT) {
function oneActionConfigured(a: AlertRuleFormAction): boolean {
if (a.type === "notify") {
return (
a.userIds.length > 0 ||
a.roleIds.length > 0 ||
a.userTags.length > 0 ||
a.roleTags.length > 0 ||
a.emailTags.length > 0
);
}
if (a.type === "sms") {
return a.phoneTags.length > 0;
}
try {
new URL(a.url.trim());
return true;
@@ -124,8 +122,6 @@ function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string {
switch (a.type) {
case "notify":
return t("alertingActionNotify");
case "sms":
return t("alertingActionSms");
case "webhook":
return t("alertingActionWebhook");
}
@@ -134,18 +130,18 @@ function actionTypeLabel(a: AlertRuleFormAction, t: AlertRuleT): string {
function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string {
if (a.type === "notify") {
if (
a.userIds.length === 0 &&
a.roleIds.length === 0 &&
a.userTags.length === 0 &&
a.roleTags.length === 0 &&
a.emailTags.length === 0
) {
return t("alertingNodeNotConfigured");
}
const parts: string[] = [];
if (a.userIds.length > 0) {
parts.push(t("alertingUsersSelected", { count: a.userIds.length }));
if (a.userTags.length > 0) {
parts.push(t("alertingUsersSelected", { count: a.userTags.length }));
}
if (a.roleIds.length > 0) {
parts.push(t("alertingRolesSelected", { count: a.roleIds.length }));
if (a.roleTags.length > 0) {
parts.push(t("alertingRolesSelected", { count: a.roleTags.length }));
}
if (a.emailTags.length > 0) {
parts.push(
@@ -154,12 +150,6 @@ function summarizeOneAction(a: AlertRuleFormAction, t: AlertRuleT): string {
}
return parts.join(" · ");
}
if (a.type === "sms") {
if (a.phoneTags.length === 0) {
return t("alertingNodeNotConfigured");
}
return `${t("alertingSmsNumbers")}: ${a.phoneTags.length}`;
}
const url = a.url.trim();
if (!url) {
return t("alertingNodeNotConfigured");
@@ -676,23 +666,16 @@ export default function AlertRuleGraphEditor({
)}
</span>
<DropdownAddAction
onPick={(type) => {
onAdd={(type) => {
const newIndex =
fields.length;
if (type === "notify") {
append({
type: "notify",
userIds: [],
roleIds: [],
userTags: [],
roleTags: [],
emailTags: []
});
} else if (
type === "sms"
) {
append({
type: "sms",
phoneTags: []
});
} else {
append({
type: "webhook",

View File

@@ -25,11 +25,10 @@ export type AlertTrigger =
export type AlertRuleFormAction =
| {
type: "notify";
userIds: string[];
roleIds: number[];
userTags: Tag[];
roleTags: Tag[];
emailTags: Tag[];
}
| { type: "sms"; phoneTags: Tag[] }
| {
type: "webhook";
url: string;
@@ -142,14 +141,10 @@ export function buildFormSchema(t: (k: string) => string) {
z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userIds: z.array(z.string()),
roleIds: z.array(z.number()),
userTags: z.array(tagSchema),
roleTags: z.array(tagSchema),
emailTags: z.array(tagSchema)
}),
z.object({
type: z.literal("sms"),
phoneTags: z.array(tagSchema)
}),
z.object({
type: z.literal("webhook"),
url: z.string(),
@@ -218,24 +213,17 @@ export function buildFormSchema(t: (k: string) => string) {
val.actions.forEach((a, i) => {
if (a.type === "notify") {
if (
a.userIds.length === 0 &&
a.roleIds.length === 0 &&
a.userTags.length === 0 &&
a.roleTags.length === 0 &&
a.emailTags.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorNotifyRecipients"),
path: ["actions", i, "userIds"]
path: ["actions", i, "userTags"]
});
}
}
if (a.type === "sms" && a.phoneTags.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("alertingErrorSmsPhones"),
path: ["actions", i, "phoneTags"]
});
}
if (a.type === "webhook") {
try {
new URL(a.url.trim());
@@ -266,8 +254,8 @@ export function defaultFormValues(): AlertRuleFormValues {
actions: [
{
type: "notify",
userIds: [],
roleIds: [],
userTags: [],
roleTags: [],
emailTags: []
}
]
@@ -287,21 +275,20 @@ export function apiResponseToFormValues(
: "health_check";
// Collect notify recipients into a single notify action (if any)
const userIds = rule.recipients
const userTags = rule.recipients
.filter((r) => r.userId != null)
.map((r) => r.userId!);
const roleIds = rule.recipients
.map((r) => ({ id: r.userId!, text: r.userId! }));
const roleTags = rule.recipients
.filter((r) => r.roleId != null)
.map((r) => parseInt(r.roleId!, 10))
.filter((n) => !isNaN(n));
.map((r) => ({ id: r.roleId!, text: r.roleId! }));
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 });
if (userTags.length > 0 || roleTags.length > 0 || emailTags.length > 0) {
actions.push({ type: "notify", userTags, roleTags, emailTags });
}
// Each webhook action becomes its own form webhook action
@@ -319,8 +306,8 @@ export function apiResponseToFormValues(
if (actions.length === 0) {
actions.push({
type: "notify",
userIds: [],
roleIds: [],
userTags: [],
roleTags: [],
emailTags: []
});
}
@@ -354,8 +341,8 @@ export function formValuesToApiPayload(
for (const action of values.actions) {
if (action.type === "notify") {
allUserIds.push(...action.userIds);
allRoleIds.push(...action.roleIds.map(String));
allUserIds.push(...action.userTags.map((t) => t.id));
allRoleIds.push(...action.roleTags.map((t) => t.id));
allEmails.push(
...action.emailTags
.map((t) => t.text.trim())
@@ -379,7 +366,6 @@ export function formValuesToApiPayload(
: {})
});
}
// sms is not supported by the backend; silently skip
}
// Deduplicate
@@ -398,4 +384,4 @@ export function formValuesToApiPayload(
emails: uniqueEmails,
webhookActions
};
}
}

View File

@@ -14,10 +14,6 @@ export const alertActionSchema = z.discriminatedUnion("type", [
roleIds: z.array(z.number()),
emails: z.array(z.string())
}),
z.object({
type: z.literal("sms"),
phoneNumbers: z.array(z.string())
}),
z.object({
type: z.literal("webhook"),
url: z.string().url(),