basic rules

This commit is contained in:
miloschwartz
2026-03-29 14:19:26 -07:00
parent 9dc9b6a2c3
commit 2841c5ed4e
6 changed files with 1955 additions and 0 deletions

View File

@@ -1321,10 +1321,78 @@
"sidebarGeneral": "Manage",
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints",
"sidebarAlerting": "Alerting",
"sidebarOrganization": "Organization",
"sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics",
"alertingTitle": "Alerting rules",
"alertingDescription": "Define sources, triggers, and actions for notifications. Rules are stored locally in this browser until server-side alerting is available.",
"alertingRules": "Alert rules",
"alertingSearchRules": "Search rules…",
"alertingAddRule": "Create rule",
"alertingColumnSource": "Source",
"alertingColumnTrigger": "Trigger",
"alertingColumnActions": "Actions",
"alertingColumnEnabled": "Enabled",
"alertingDeleteQuestion": "Delete this alert rule? This cannot be undone.",
"alertingDeleteRule": "Delete alert rule",
"alertingRuleDeleted": "Alert rule deleted",
"alertingRuleSaved": "Alert rule saved",
"alertingEditRule": "Edit alert rule",
"alertingCreateRule": "Create alert rule",
"alertingRuleCredenzaDescription": "Choose what to watch, when to fire, and how to notify your team.",
"alertingRuleNamePlaceholder": "Production site down",
"alertingRuleEnabled": "Rule enabled",
"alertingSectionSource": "Source",
"alertingSourceType": "Source type",
"alertingSourceSite": "Site",
"alertingSourceHealthCheck": "Health check",
"alertingPickSites": "Sites",
"alertingPickHealthChecks": "Health checks",
"alertingSectionTrigger": "Trigger",
"alertingTrigger": "When to alert",
"alertingTriggerSiteOnline": "Site online",
"alertingTriggerSiteOffline": "Site offline",
"alertingTriggerHcHealthy": "Health check healthy",
"alertingTriggerHcUnhealthy": "Health check unhealthy",
"alertingSectionActions": "Actions",
"alertingAddAction": "Add action",
"alertingActionNotify": "Notify",
"alertingActionSms": "SMS",
"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",
"alertingWebhookHeaders": "Headers",
"alertingAddHeader": "Add header",
"alertingSelectSites": "Select sites…",
"alertingSitesSelected": "{count} sites selected",
"alertingSelectHealthChecks": "Select health checks…",
"alertingHealthChecksSelected": "{count} health checks selected",
"alertingNoHealthChecks": "No targets with health checks enabled",
"alertingSelectUsers": "Select users…",
"alertingUsersSelected": "{count} users selected",
"alertingSelectRoles": "Select roles…",
"alertingRolesSelected": "{count} roles selected",
"alertingSummarySites": "Sites ({count})",
"alertingSummaryHealthChecks": "Health checks ({count})",
"alertingErrorNameRequired": "Enter a name",
"alertingErrorActionsMin": "Add at least one action",
"alertingErrorPickSites": "Select at least one site",
"alertingErrorPickHealthChecks": "Select at least one health check",
"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",
"blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs",
"blueprintAdd": "Add Blueprint",

View File

@@ -0,0 +1,24 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import AlertingRulesTable from "@app/components/AlertingRulesTable";
import { getTranslations } from "next-intl/server";
type AlertingPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function AlertingPage(props: AlertingPageProps) {
const params = await props.params;
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("alertingTitle")}
description={t("alertingDescription")}
/>
<AlertingRulesTable orgId={params.orgId} />
</>
);
}

View File

@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
BellRing,
Boxes,
Building2,
Cable,
@@ -219,6 +220,11 @@ export const orgNavSections = (
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
},
{
title: "sidebarAlerting",
href: "/{orgId}/settings/alerting",
icon: <BellRing className="size-4 flex-none" />
}
]
},

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,297 @@
"use client";
import AlertRuleCredenza from "@app/components/AlertRuleCredenza";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import {
DataTable,
ExtendedColumnDef
} from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast";
import {
type AlertRule,
deleteRule,
isoNow,
loadRules,
upsertRule
} from "@app/lib/alertRulesLocalStorage";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import moment from "moment";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@app/components/ui/badge";
type AlertingRulesTableProps = {
orgId: string;
};
function sourceSummary(rule: AlertRule, t: (k: string, o?: Record<string, number | string>) => string) {
if (rule.source.type === "site") {
return t("alertingSummarySites", {
count: rule.source.siteIds.length
});
}
return t("alertingSummaryHealthChecks", {
count: rule.source.targetIds.length
});
}
function triggerLabel(rule: AlertRule, t: (k: string) => string) {
switch (rule.trigger) {
case "site_online":
return t("alertingTriggerSiteOnline");
case "site_offline":
return t("alertingTriggerSiteOffline");
case "health_check_healthy":
return t("alertingTriggerHcHealthy");
case "health_check_unhealthy":
return t("alertingTriggerHcUnhealthy");
default:
return rule.trigger;
}
}
function actionBadges(rule: AlertRule, t: (k: string) => string) {
return rule.actions.map((a, i) => {
if (a.type === "notify") {
return (
<Badge key={`notify-${i}`} variant="secondary">
{t("alertingActionNotify")}
</Badge>
);
}
if (a.type === "sms") {
return (
<Badge key={`sms-${i}`} variant="secondary">
{t("alertingActionSms")}
</Badge>
);
}
return (
<Badge key={`webhook-${i}`} variant="secondary">
{t("alertingActionWebhook")}
</Badge>
);
});
}
export default function AlertingRulesTable({ orgId }: AlertingRulesTableProps) {
const t = useTranslations();
const [rows, setRows] = useState<AlertRule[]>([]);
const [credenzaOpen, setCredenzaOpen] = useState(false);
const [credenzaRule, setCredenzaRule] = useState<AlertRule | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [selected, setSelected] = useState<AlertRule | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const refreshFromStorage = useCallback(() => {
setRows(loadRules(orgId));
}, [orgId]);
useEffect(() => {
refreshFromStorage();
}, [refreshFromStorage]);
const refreshData = async () => {
setIsRefreshing(true);
try {
await new Promise((r) => setTimeout(r, 200));
refreshFromStorage();
} finally {
setIsRefreshing(false);
}
};
const setEnabled = (rule: AlertRule, enabled: boolean) => {
upsertRule(orgId, { ...rule, enabled, updatedAt: isoNow() });
refreshFromStorage();
};
const confirmDelete = async () => {
if (!selected) return;
deleteRule(orgId, selected.id);
refreshFromStorage();
setDeleteOpen(false);
setSelected(null);
toast({ title: t("alertingRuleDeleted") });
};
const columns: ExtendedColumnDef<AlertRule>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{row.original.name}</span>
)
},
{
id: "source",
friendlyName: t("alertingColumnSource"),
header: () => <span className="p-3">{t("alertingColumnSource")}</span>,
cell: ({ row }) => (
<span>{sourceSummary(row.original, t)}</span>
)
},
{
id: "trigger",
friendlyName: t("alertingColumnTrigger"),
header: () => (
<span className="p-3">{t("alertingColumnTrigger")}</span>
),
cell: ({ row }) => <span>{triggerLabel(row.original, t)}</span>
},
{
id: "actionsCol",
friendlyName: t("alertingColumnActions"),
header: () => (
<span className="p-3">{t("alertingColumnActions")}</span>
),
cell: ({ row }) => (
<div className="flex flex-wrap gap-1 max-w-[14rem]">
{actionBadges(row.original, t)}
</div>
)
},
{
accessorKey: "enabled",
friendlyName: t("alertingColumnEnabled"),
header: () => (
<span className="p-3">{t("alertingColumnEnabled")}</span>
),
cell: ({ row }) => {
const r = row.original;
return (
<Switch
checked={r.enabled}
onCheckedChange={(v) => setEnabled(r, v)}
/>
);
}
},
{
accessorKey: "createdAt",
friendlyName: t("createdAt"),
header: () => (
<span className="p-3">{t("createdAt")}</span>
),
cell: ({ row }) => (
<span>{moment(row.original.createdAt).format("lll")}</span>
)
},
{
id: "rowActions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setCredenzaRule(r);
setCredenzaOpen(true);
}}
>
{t("edit")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(r);
setDeleteOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
}
];
return (
<>
<AlertRuleCredenza
open={credenzaOpen}
setOpen={(v) => {
setCredenzaOpen(v);
if (!v) setCredenzaRule(null);
}}
orgId={orgId}
rule={credenzaRule}
onSaved={refreshFromStorage}
/>
{selected && (
<ConfirmDeleteDialog
open={deleteOpen}
setOpen={(val) => {
setDeleteOpen(val);
if (!val) setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("alertingDeleteQuestion")}</p>
</div>
}
buttonText={t("delete")}
onConfirm={confirmDelete}
string={selected.name}
title={t("alertingDeleteRule")}
/>
)}
<DataTable
columns={columns}
data={rows}
persistPageSize="Org-alerting-rules-table"
title={t("alertingRules")}
searchPlaceholder={t("alertingSearchRules")}
searchColumn="name"
onAdd={() => {
setCredenzaRule(null);
setCredenzaOpen(true);
}}
onRefresh={refreshData}
isRefreshing={isRefreshing}
addButtonText={t("alertingAddRule")}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="rowActions"
/>
</>
);
}

View File

@@ -0,0 +1,129 @@
import { z } from "zod";
const STORAGE_PREFIX = "pangolin:alert-rules:";
export const webhookHeaderEntrySchema = z.object({
key: z.string(),
value: z.string()
});
export const alertActionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("notify"),
userIds: z.array(z.string()),
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(),
method: z.string().min(1),
headers: z.array(webhookHeaderEntrySchema),
secret: z.string().optional()
})
]);
export const alertSourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("site"),
siteIds: z.array(z.number())
}),
z.object({
type: z.literal("health_check"),
targetIds: z.array(z.number())
})
]);
export const alertTriggerSchema = z.enum([
"site_online",
"site_offline",
"health_check_healthy",
"health_check_unhealthy"
]);
export const alertRuleSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255),
enabled: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
source: alertSourceSchema,
trigger: alertTriggerSchema,
actions: z.array(alertActionSchema).min(1)
});
export type AlertRule = z.infer<typeof alertRuleSchema>;
export type AlertAction = z.infer<typeof alertActionSchema>;
export type AlertTrigger = z.infer<typeof alertTriggerSchema>;
function storageKey(orgId: string) {
return `${STORAGE_PREFIX}${orgId}`;
}
export function loadRules(orgId: string): AlertRule[] {
if (typeof window === "undefined") {
return [];
}
try {
const raw = localStorage.getItem(storageKey(orgId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
const out: AlertRule[] = [];
for (const item of parsed) {
const r = alertRuleSchema.safeParse(item);
if (r.success) {
out.push(r.data);
}
}
return out;
} catch {
return [];
}
}
export function saveRules(orgId: string, rules: AlertRule[]) {
if (typeof window === "undefined") {
return;
}
localStorage.setItem(storageKey(orgId), JSON.stringify(rules));
}
export function upsertRule(orgId: string, rule: AlertRule) {
const rules = loadRules(orgId);
const i = rules.findIndex((r) => r.id === rule.id);
if (i >= 0) {
rules[i] = rule;
} else {
rules.push(rule);
}
saveRules(orgId, rules);
}
export function deleteRule(orgId: string, ruleId: string) {
const rules = loadRules(orgId).filter((r) => r.id !== ruleId);
saveRules(orgId, rules);
}
export function newRuleId() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export function isoNow() {
return new Date().toISOString();
}