A threshold rule composer with metric, operator, value, and duration inputs and a live SVG preview that flags breaching points.
npx shadcn@latest add @paragon/alert-rule-builder"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>
);
}