Infrastructure & DevOps

Alert Rule Builder

A threshold rule composer with metric, operator, value, and duration inputs and a live SVG preview that flags breaching points.

Install

npx shadcn@latest add @paragon/alert-rule-builder

alert-rule-builder.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export type Operator = ">" | ">=" | "<" | "<=";

export interface AlertRule {
  metric: string;
  operator: Operator;
  threshold: number;
  /** Sustained-for window in minutes. */
  duration: number;
}

export interface AlertRuleBuilderProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  metrics?: string[];
  /** Sample series for the preview, matched to the selected metric by index. */
  series?: Record<string, number[]>;
  defaultRule?: AlertRule;
  onChange?: (rule: AlertRule) => void;
}

const DEFAULT_METRICS = [
  "cpu.utilization",
  "http.p99_latency",
  "error.rate",
  "queue.depth",
];

const DEFAULT_SERIES: Record<string, number[]> = {
  "cpu.utilization": [42, 48, 55, 61, 70, 78, 84, 88, 82, 76, 71, 69, 74, 80, 86, 91, 88, 83],
  "http.p99_latency": [180, 210, 240, 300, 360, 420, 390, 350, 310, 280, 260, 300, 380, 440, 500, 470, 410, 360],
  "error.rate": [0.2, 0.3, 0.2, 0.5, 0.9, 1.4, 2.1, 1.8, 1.2, 0.8, 0.6, 0.9, 1.5, 2.3, 2.9, 2.4, 1.6, 1.1],
  "queue.depth": [3, 4, 6, 8, 12, 18, 24, 22, 16, 11, 8, 9, 14, 20, 28, 25, 17, 12],
};

const OPERATORS: Operator[] = [">", ">=", "<", "<="];

function breaches(v: number, op: Operator, t: number) {
  switch (op) {
    case ">":
      return v > t;
    case ">=":
      return v >= t;
    case "<":
      return v < t;
    case "<=":
      return v <= t;
  }
}

export function AlertRuleBuilder({
  metrics = DEFAULT_METRICS,
  series = DEFAULT_SERIES,
  defaultRule,
  onChange,
  className,
  ...props
}: AlertRuleBuilderProps) {
  const [metric, setMetric] = React.useState(defaultRule?.metric ?? metrics[0]);
  const [operator, setOperator] = React.useState<Operator>(
    defaultRule?.operator ?? ">",
  );
  const data = series[metric] ?? [];
  const max = Math.max(...data, 1);
  const [threshold, setThreshold] = React.useState(
    defaultRule?.threshold ?? Math.round(max * 0.7),
  );
  const [duration, setDuration] = React.useState(defaultRule?.duration ?? 5);

  const onChangeRef = React.useRef(onChange);
  onChangeRef.current = onChange;
  React.useEffect(() => {
    onChangeRef.current?.({ metric, operator, threshold, duration });
  }, [metric, operator, threshold, duration]);

  // preview geometry
  const w = 320;
  const h = 90;
  const min = Math.min(...data, 0);
  const span = max - min || 1;
  const step = data.length > 1 ? w / (data.length - 1) : w;
  const yFor = (v: number) => h - ((v - min) / span) * (h - 8) - 4;
  const linePts = data
    .map((v, i) => `${i === 0 ? "M" : "L"} ${(i * step).toFixed(1)} ${yFor(v).toFixed(1)}`)
    .join(" ");
  const tY = yFor(threshold);
  const breachCount = data.filter((v) => breaches(v, operator, threshold)).length;

  const selectCls =
    "h-8 rounded-md border border-input bg-background px-2 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring";

  return (
    <div
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex flex-wrap items-center gap-2 text-xs">
        <span className="text-muted-foreground">Alert when</span>
        <select
          aria-label="Metric"
          value={metric}
          onChange={(e) => {
            const m = e.target.value;
            setMetric(m);
            setThreshold(Math.round(Math.max(...(series[m] ?? [1])) * 0.7));
          }}
          className={cn(selectCls, "font-mono")}
        >
          {metrics.map((m) => (
            <option key={m} value={m}>
              {m}
            </option>
          ))}
        </select>
        <select
          aria-label="Operator"
          value={operator}
          onChange={(e) => setOperator(e.target.value as Operator)}
          className={selectCls}
        >
          {OPERATORS.map((op) => (
            <option key={op} value={op}>
              {op}
            </option>
          ))}
        </select>
        <input
          aria-label="Threshold"
          type="number"
          value={threshold}
          onChange={(e) => setThreshold(Number(e.target.value))}
          className={cn(selectCls, "w-20 tabular-nums")}
        />
      </div>

      <div className="mt-2 flex items-center gap-2 text-xs">
        <span className="text-muted-foreground">sustained for</span>
        <input
          aria-label="Duration minutes"
          type="range"
          min={1}
          max={30}
          value={duration}
          onChange={(e) => setDuration(Number(e.target.value))}
          className="paragon-range h-4 flex-1"
        />
        <span className="w-14 shrink-0 text-right font-medium tabular-nums">
          {duration} min
        </span>
      </div>

      <div className="relative mt-3 rounded-lg border border-border bg-background p-2">
        <svg viewBox={`0 0 ${w} ${h}`} className="w-full" role="img" aria-label="Threshold preview">
          {/* breach fill above/below threshold */}
          <line
            x1={0}
            x2={w}
            y1={tY}
            y2={tY}
            stroke="var(--color-destructive)"
            strokeWidth={1}
            strokeDasharray="4 3"
            className="transition-[transform] duration-200 ease-out"
          />
          <path
            d={linePts}
            fill="none"
            stroke="var(--color-foreground)"
            strokeWidth={1.5}
            strokeLinejoin="round"
            strokeLinecap="round"
            vectorEffect="non-scaling-stroke"
          />
          {data.map((v, i) => {
            if (!breaches(v, operator, threshold)) return null;
            return (
              <circle
                key={i}
                cx={i * step}
                cy={yFor(v)}
                r={2.5}
                fill="var(--color-destructive)"
              />
            );
          })}
        </svg>
        <span
          className="pointer-events-none absolute right-2 rounded bg-destructive px-1 py-0.5 text-[10px] font-medium tabular-nums text-destructive-foreground"
          style={{ top: `calc(${(tY / h) * 100}% - 8px)` }}
        >
          {threshold}
        </span>
      </div>

      <p className="mt-2 text-[11px] text-muted-foreground">
        <span
          className="font-medium tabular-nums"
          style={{ color: breachCount ? "var(--color-destructive)" : "var(--color-success)" }}
        >
          {breachCount}
        </span>{" "}
        of {data.length} sampled points breach this rule.
      </p>
    </div>
  );
}