import { useActionMutation, useActionQuery, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconBell, IconChevronDown, IconLoader2, IconPencil, IconPlayerPlay, IconPlus, IconTrash, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; type AlertFilterOp = "equals" | "not_equals" | "contains" | "in" | "exists"; interface AnalyticsAlertFilter { field: string; op?: AlertFilterOp; value?: unknown; } type AlertThresholdMode = "event_count" | "distinct_count"; type AlertSeverity = "warning" | "critical"; type AlertStatus = "ok" | "triggered" | "cooldown" | "error" | "running"; type KnownChannel = "inbox" | "email" | "slack" | "webhook"; interface AnalyticsAlertRule { id: string; name: string; description: string; eventName: string | null; filters: AnalyticsAlertFilter[]; thresholdMode: AlertThresholdMode; distinctBy: string | null; threshold: number; windowMinutes: number; cooldownMinutes: number; severity: AlertSeverity; channels: string[]; emailRecipients: string[]; slackWebhookUrl: string | null; webhookUrl: string | null; enabled: boolean; lastEvaluatedAt: string | null; lastTriggeredAt: string | null; lastStatus: AlertStatus | null; lastError: string | null; createdAt: string; updatedAt: string; } interface RunAlertsResult { processed: number; triggered: number; failed: number; remaining: number; } interface AlertRuleDefaults { emailRecipients: string[]; } interface AlertRuleFormState { id?: string; name: string; description: string; eventName: string; filtersJson: string; thresholdMode: AlertThresholdMode; distinctBy: string; threshold: string; windowMinutes: string; cooldownMinutes: string; severity: AlertSeverity; enabled: boolean; channels: Record; customChannels: string; emailRecipients: string; slackWebhookUrl: string; webhookUrl: string; } const KNOWN_CHANNELS = ["inbox", "email", "slack", "webhook"] as const; function emptyAlertForm( defaults?: AlertRuleDefaults | null, ): AlertRuleFormState { const emailRecipients = defaults?.emailRecipients ?? []; return { name: "", description: "", eventName: "", filtersJson: "[]", thresholdMode: "event_count", distinctBy: "user_key", threshold: "5", windowMinutes: "10", cooldownMinutes: "30", severity: "warning", enabled: true, channels: { inbox: true, email: emailRecipients.length > 0, slack: false, webhook: false, }, customChannels: "", emailRecipients: emailRecipients.join("\n"), slackWebhookUrl: "", webhookUrl: "", }; } function formFromRule(rule: AnalyticsAlertRule): AlertRuleFormState { const channelSet = new Set(rule.channels); const customChannels = rule.channels.filter( (channel): channel is string => !KNOWN_CHANNELS.includes(channel as KnownChannel), ); return { id: rule.id, name: rule.name, description: rule.description, eventName: rule.eventName ?? "", filtersJson: JSON.stringify(rule.filters ?? [], null, 2), thresholdMode: rule.thresholdMode, distinctBy: rule.distinctBy ?? "user_key", threshold: String(rule.threshold), windowMinutes: String(rule.windowMinutes), cooldownMinutes: String(rule.cooldownMinutes), severity: rule.severity, enabled: rule.enabled, channels: { inbox: channelSet.has("inbox"), email: channelSet.has("email"), slack: channelSet.has("slack"), webhook: channelSet.has("webhook"), }, customChannels: customChannels.join(", "), emailRecipients: rule.emailRecipients.join("\n"), slackWebhookUrl: rule.slackWebhookUrl ?? "", webhookUrl: rule.webhookUrl ?? "", }; } function splitList(value: string): string[] { return value .split(/[\n,]+/) .map((item) => item.trim()) .filter(Boolean); } function parseInteger(value: string, fallback: number): number { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : fallback; } function isHttpUrl(value: string): boolean { try { const parsed = new URL(value); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } function formatDate(value: string | null, fallback: string): string { if (!value) return fallback; const date = new Date(value); if (Number.isNaN(date.getTime())) return fallback; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function formatWindow(minutes: number): string { if (minutes < 60) return `${minutes}m`; if (minutes % 60 === 0) return `${minutes / 60}h`; return `${minutes}m`; } function channelsFromForm(form: AlertRuleFormState): string[] { const selected = KNOWN_CHANNELS.filter((channel) => form.channels[channel]); const custom = splitList(form.customChannels); return [...selected, ...custom]; } function payloadFromRule( rule: AnalyticsAlertRule, overrides: Partial = {}, ) { return { id: rule.id, name: rule.name, description: rule.description, eventName: rule.eventName, filters: rule.filters, thresholdMode: rule.thresholdMode, distinctBy: rule.distinctBy, threshold: rule.threshold, windowMinutes: rule.windowMinutes, cooldownMinutes: rule.cooldownMinutes, severity: rule.severity, channels: rule.channels, emailRecipients: rule.emailRecipients, slackWebhookUrl: rule.slackWebhookUrl, webhookUrl: rule.webhookUrl, enabled: rule.enabled, ...overrides, }; } function parseFilters(filtersJson: string): AnalyticsAlertFilter[] { const trimmed = filtersJson.trim(); if (!trimmed) return []; const parsed = JSON.parse(trimmed) as unknown; if (!Array.isArray(parsed)) { throw new Error("filters must be a JSON array"); } return parsed as AnalyticsAlertFilter[]; } export function AlertRulesSettingsCard() { const t = useT(); const queryClient = useQueryClient(); const [editing, setEditing] = useState(null); const [expandedRuleIds, setExpandedRuleIds] = useState>( () => new Set(), ); const [ruleToDelete, setRuleToDelete] = useState( null, ); const { data, isLoading, refetch } = useActionQuery( "list-analytics-alert-rules", undefined, { staleTime: 10_000 }, ); const { data: alertDefaults } = useActionQuery( "get-analytics-alert-rule-defaults", undefined, { staleTime: 10_000 }, ); const saveRule = useActionMutation("save-analytics-alert-rule"); const deleteRule = useActionMutation("delete-analytics-alert-rule"); const runAlerts = useActionMutation("run-analytics-alerts"); const rules = useMemo(() => (Array.isArray(data) ? data : []), [data]); const enabledCount = rules.filter((rule) => rule.enabled).length; async function refreshAlerts() { await queryClient.invalidateQueries({ queryKey: ["action", "list-analytics-alert-rules"], }); await queryClient.invalidateQueries({ queryKey: ["action", "get-analytics-alert-rule-defaults"], }); await refetch(); } function startCreateAlert() { setEditing(emptyAlertForm(alertDefaults)); } function setRuleExpanded(ruleId: string, expanded: boolean) { setExpandedRuleIds((current) => { const next = new Set(current); if (expanded) { next.add(ruleId); } else { next.delete(ruleId); } return next; }); } async function handleSave() { if (!editing) return; let filters: AnalyticsAlertFilter[]; try { filters = parseFilters(editing.filtersJson); } catch (err) { toast.error( t("settings.alertFiltersInvalid", { message: err instanceof Error ? err.message : String(err), }), ); return; } const name = editing.name.trim(); if (!name) { toast.error(t("settings.alertNameRequired")); return; } const channels = channelsFromForm(editing); if (channels.length === 0) { toast.error(t("settings.alertChannelRequired")); return; } if ( editing.channels.slack && editing.slackWebhookUrl.trim() && !isHttpUrl(editing.slackWebhookUrl.trim()) ) { toast.error(t("settings.alertSlackWebhookUrlInvalid")); return; } if ( editing.channels.webhook && editing.webhookUrl.trim() && !isHttpUrl(editing.webhookUrl.trim()) ) { toast.error(t("settings.alertWebhookUrlInvalid")); return; } try { await saveRule.mutateAsync({ id: editing.id, name, description: editing.description.trim(), eventName: editing.eventName.trim() || null, filters, thresholdMode: editing.thresholdMode, distinctBy: editing.thresholdMode === "distinct_count" ? editing.distinctBy.trim() || "user_key" : editing.distinctBy.trim() || null, threshold: parseInteger(editing.threshold, 1), windowMinutes: parseInteger(editing.windowMinutes, 10), cooldownMinutes: parseInteger(editing.cooldownMinutes, 30), severity: editing.severity, channels, emailRecipients: splitList(editing.emailRecipients), slackWebhookUrl: editing.channels.slack ? editing.slackWebhookUrl.trim() : null, webhookUrl: editing.channels.webhook ? editing.webhookUrl.trim() : null, enabled: editing.enabled, }); setEditing(null); await refreshAlerts(); toast.success(t("settings.alertSaved")); } catch (err) { toast.error( t("settings.alertSaveFailed", { message: err instanceof Error ? err.message : String(err), }), ); } } async function handleToggle(rule: AnalyticsAlertRule, enabled: boolean) { try { await saveRule.mutateAsync(payloadFromRule(rule, { enabled })); await refreshAlerts(); toast.success( enabled ? t("settings.alertEnabledToast") : t("settings.alertDisabledToast"), ); } catch (err) { toast.error( t("settings.alertSaveFailed", { message: err instanceof Error ? err.message : String(err), }), ); } } async function handleDelete() { if (!ruleToDelete) return; try { await deleteRule.mutateAsync({ id: ruleToDelete.id }); setRuleToDelete(null); await refreshAlerts(); toast.success(t("settings.alertDeleted")); } catch (err) { toast.error( t("settings.alertDeleteFailed", { message: err instanceof Error ? err.message : String(err), }), ); } } async function handleRunAlerts() { try { const result = (await runAlerts.mutateAsync({ limit: 200, })) as RunAlertsResult; await refreshAlerts(); toast.success( t("settings.alertRunComplete", { processed: result.processed, triggered: result.triggered, failed: result.failed, }), ); } catch (err) { toast.error( t("settings.alertRunFailed", { message: err instanceof Error ? err.message : String(err), }), ); } } return ( <>
{t("settings.alertsTitle")} {t("settings.alertsDescription")}
{isLoading ? (
{[0, 1, 2].map((item) => (
))}
) : rules.length === 0 ? (

{t("settings.alertsEmptyTitle")}

{t("settings.alertsEmptyDescription")}

) : (
{rules.map((rule) => { const expanded = expandedRuleIds.has(rule.id); return ( setRuleExpanded(rule.id, open)} className="rounded-lg border border-border/60 bg-background/40" >
void handleToggle(rule, enabled) } aria-label={t("settings.alertToggleLabel", { name: rule.name, })} className="mt-0.5 shrink-0" />
{rule.name} {rule.severity === "critical" ? t("settings.alertSeverityCritical") : t("settings.alertSeverityWarning")}

{rule.description || formatScope(rule, t)}

{formatThreshold(rule, t)}
{statusLabel(rule.lastStatus, t)}
knownChannelLabel(channel, t)) .join(", ")} detail={ rule.emailRecipients.length > 0 ? rule.emailRecipients.join(", ") : undefined } /> 0 ? JSON.stringify(rule.filters) : "[]" } />
{rule.lastError ? (
{rule.lastError}
) : null}
); })}
)} !open && setRuleToDelete(null)} > {t("settings.alertDeleteTitle")} {t("settings.alertDeleteDescription", { name: ruleToDelete?.name ?? "", })} {t("sidebar.cancel")} void handleDelete()} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" disabled={deleteRule.isPending} > {deleteRule.isPending && ( )} {t("sidebar.delete")} ); } function AlertRuleDetail({ label, value, detail, }: { label: string; value: string; detail?: string; }) { return (
{label}
{value}
{detail ? (
{detail}
) : null}
); } function AlertRuleDialog({ form, setForm, onSave, saving, }: { form: AlertRuleFormState | null; setForm: (form: AlertRuleFormState | null) => void; onSave: () => void; saving: boolean; }) { const t = useT(); if (!form) return null; const setField = ( key: K, value: AlertRuleFormState[K], ) => { setForm({ ...form, [key]: value }); }; const setChannel = (channel: KnownChannel, checked: boolean) => { setField("channels", { ...form.channels, [channel]: checked, }); }; return ( !open && setForm(null)}> {form.id ? t("settings.alertEdit") : t("settings.alertCreate")} {t("settings.alertDialogDescription")}
setField("name", event.target.value)} placeholder={t("settings.alertNamePlaceholder")} />