first commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* CEL Rule Builder for Routing Rules
|
||||
* Thin wrapper around the reusable CELRuleBuilder with routing-specific config
|
||||
*/
|
||||
|
||||
import { CELRuleBuilder as BaseCELRuleBuilder } from "@/components/ui/custom/celBuilder";
|
||||
import { getRoutingFields } from "@/lib/config/celFieldsRouting";
|
||||
import { celOperatorsRouting } from "@/lib/config/celOperatorsRouting";
|
||||
import { convertRuleGroupToCEL, validateRegexPattern } from "@/lib/utils/celConverterRouting";
|
||||
import { useMemo } from "react";
|
||||
import { RuleGroupType } from "react-querybuilder";
|
||||
|
||||
interface CELRuleBuilderProps {
|
||||
onChange?: (celExpression: string, query: RuleGroupType) => void;
|
||||
initialQuery?: RuleGroupType;
|
||||
providers?: string[];
|
||||
models?: string[];
|
||||
allowCustomModels?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function CELRuleBuilder({
|
||||
onChange,
|
||||
initialQuery,
|
||||
providers = [],
|
||||
models = [],
|
||||
isLoading = false,
|
||||
allowCustomModels = false,
|
||||
}: CELRuleBuilderProps) {
|
||||
const fields = useMemo(() => getRoutingFields(providers, models), [providers, models]);
|
||||
|
||||
return (
|
||||
<BaseCELRuleBuilder
|
||||
onChange={onChange}
|
||||
initialQuery={initialQuery}
|
||||
isLoading={isLoading}
|
||||
fields={fields}
|
||||
operators={celOperatorsRouting}
|
||||
convertToCEL={convertRuleGroupToCEL}
|
||||
validateRegex={validateRegexPattern}
|
||||
builderContext={{ allowCustomModels }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
ui/app/workspace/routing-rules/layout.tsx
Normal file
11
ui/app/workspace/routing-rules/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute, Outlet, useChildMatches } from "@tanstack/react-router";
|
||||
import RoutingRulesPage from "./page";
|
||||
|
||||
function RouteComponent() {
|
||||
const childMatches = useChildMatches();
|
||||
return childMatches.length === 0 ? <RoutingRulesPage /> : <Outlet />;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/workspace/routing-rules")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
14
ui/app/workspace/routing-rules/page.tsx
Normal file
14
ui/app/workspace/routing-rules/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Routing Rules Page
|
||||
* Main container for routing rules management
|
||||
*/
|
||||
|
||||
import { RoutingRulesView } from "./views/routingRulesView";
|
||||
|
||||
export default function RoutingRulesPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
<RoutingRulesView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
ui/app/workspace/routing-rules/tree/layout.tsx
Normal file
6
ui/app/workspace/routing-rules/tree/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import RoutingTreePage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/routing-rules/tree")({
|
||||
component: RoutingTreePage,
|
||||
});
|
||||
19
ui/app/workspace/routing-rules/tree/page.tsx
Normal file
19
ui/app/workspace/routing-rules/tree/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Routing Tree Page
|
||||
* Full-canvas read-only routing rules decision tree visualizer.
|
||||
*/
|
||||
|
||||
import { RoutingTreeView } from "./views/routingTreeView";
|
||||
|
||||
export const metadata = {
|
||||
title: "Routing Tree | Bifrost",
|
||||
description: "Read-only decision tree visualization of routing rules",
|
||||
};
|
||||
|
||||
export default function RoutingTreePage() {
|
||||
return (
|
||||
<div className="no-padding-parent no-border-parent h-[calc(100dvh_)] w-full">
|
||||
<RoutingTreeView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
ui/app/workspace/routing-rules/tree/views/celParser.ts
Normal file
158
ui/app/workspace/routing-rules/tree/views/celParser.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* CEL (Common Expression Language) parsing and evaluation helpers.
|
||||
*
|
||||
* Only handles the subset of CEL actually used in routing rule conditions:
|
||||
* equality/inequality, startsWith, contains, in-list, header access, and
|
||||
* simple numeric comparisons.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Evaluate a single normalised CEL clause against a resolved variable map.
|
||||
* Only handles simple equality/inequality patterns (field == "v", "v" == field,
|
||||
* field != "v", "v" != field). Returns null when too complex to evaluate.
|
||||
*/
|
||||
export function evalChainCondition(cond: string, vars: Record<string, string>): boolean | null {
|
||||
const s = cond.trim();
|
||||
let m: RegExpMatchArray | null;
|
||||
|
||||
// Simple equality: field == "v" or "v" == field
|
||||
m = s.match(/^(\w+)\s*==\s*["']([^"']*)["']$/);
|
||||
if (m && m[1] in vars) return vars[m[1]] === m[2];
|
||||
m = s.match(/^["']([^"']*)['"]\s*==\s*(\w+)$/);
|
||||
if (m && m[2] in vars) return vars[m[2]] === m[1];
|
||||
|
||||
// Inequality: field != "v" or "v" != field
|
||||
m = s.match(/^(\w+)\s*!=\s*["']([^"']*)["']$/);
|
||||
if (m && m[1] in vars) return vars[m[1]] !== m[2];
|
||||
m = s.match(/^["']([^"']*)['"]\s*!=\s*(\w+)$/);
|
||||
if (m && m[2] in vars) return vars[m[2]] !== m[1];
|
||||
|
||||
// startsWith: field.startsWith("prefix")
|
||||
m = s.match(/^(\w+)\.startsWith\(["']([^"']*)["']\)$/);
|
||||
if (m && m[1] in vars) return vars[m[1]].startsWith(m[2]);
|
||||
|
||||
// contains: field.contains("sub")
|
||||
m = s.match(/^(\w+)\.contains\(["']([^"']*)["']\)$/);
|
||||
if (m && m[1] in vars) return vars[m[1]].includes(m[2]);
|
||||
|
||||
// in list: field in ["a","b","c"]
|
||||
m = s.match(/^(\w+)\s+in\s+\[([^\]]*)\]$/);
|
||||
if (m && m[1] in vars) {
|
||||
const items = m[2].split(",").map((x) => x.trim().replace(/^["']|["']$/g, ""));
|
||||
return items.includes(vars[m[1]]);
|
||||
}
|
||||
|
||||
// headers["key"] == "value"
|
||||
m = s.match(/^headers\[["']([^"']*)["']\]\s*==\s*["']([^"']*)["']$/);
|
||||
if (m) {
|
||||
const hVal = vars[`headers.${m[1]}`] ?? vars[`header_${m[1]}`];
|
||||
if (hVal !== undefined) return hVal === m[2];
|
||||
}
|
||||
|
||||
// Numeric comparisons: field >= n, field <= n, field > n, field < n
|
||||
m = s.match(/^(\w+)\s*(>=|<=|>|<)\s*(\d+(?:\.\d+)?)$/);
|
||||
if (m && m[1] in vars) {
|
||||
const lv = parseFloat(vars[m[1]]);
|
||||
const rv = parseFloat(m[3]);
|
||||
if (!isNaN(lv)) {
|
||||
if (m[2] === ">") return lv > rv;
|
||||
if (m[2] === "<") return lv < rv;
|
||||
if (m[2] === ">=") return lv >= rv;
|
||||
if (m[2] === "<=") return lv <= rv;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // too complex — skip
|
||||
}
|
||||
|
||||
function isWrappedInParens(s: string): boolean {
|
||||
if (!s.startsWith("(") || !s.endsWith(")")) return false;
|
||||
let d = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === "(") d++;
|
||||
else if (s[i] === ")") d--;
|
||||
if (d === 0 && i < s.length - 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitOn(expr: string, op: "&&" | "||"): string[] {
|
||||
const trimmed = expr.trim();
|
||||
const s = isWrappedInParens(trimmed) ? trimmed.slice(1, -1) : trimmed;
|
||||
const parts: string[] = [];
|
||||
let depth = 0,
|
||||
current = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === "(" || ch === "[") depth++;
|
||||
else if (ch === ")" || ch === "]") depth--;
|
||||
else if (depth === 0 && s.slice(i, i + 2) === op) {
|
||||
const p = current.trim();
|
||||
if (p) parts.push(p);
|
||||
current = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const last = current.trim();
|
||||
if (last) parts.push(last);
|
||||
if (parts.length < 2) return [expr.trim()];
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Cartesian product of two arrays of string arrays. */
|
||||
function cartesian(a: string[][], b: string[][]): string[][] {
|
||||
const result: string[][] = [];
|
||||
for (const x of a) for (const y of b) result.push([...x, ...y]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Expand a CEL string into one or more condition lists, fanning out on OR.
|
||||
* Handles nested disjunctions such as `a && (b || c)` → [["a","b"],["a","c"]].
|
||||
*/
|
||||
export function expandCEL(cel: string): string[][] {
|
||||
const trimmed = cel?.trim() || "";
|
||||
if (!trimmed) return [[]];
|
||||
// OR has lower precedence than AND → split on || first (outer level)
|
||||
const orBranches = splitOn(trimmed, "||");
|
||||
const result: string[][] = [];
|
||||
for (const branch of orBranches) {
|
||||
const andParts = splitOn(branch.trim(), "&&")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (!andParts.length) {
|
||||
result.push([branch.trim()]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// For each AND part, check if it is a parenthesized OR — expand recursively
|
||||
// and Cartesian-product with the accumulated combinations so far.
|
||||
let combinations: string[][] = [[]];
|
||||
for (const part of andParts) {
|
||||
if (isWrappedInParens(part)) {
|
||||
const inner = part.slice(1, -1).trim();
|
||||
const innerBranches = splitOn(inner, "||");
|
||||
if (innerBranches.length > 1) {
|
||||
const innerExpanded = innerBranches.flatMap((b) => expandCEL(b.trim()));
|
||||
combinations = cartesian(combinations, innerExpanded);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
combinations = combinations.map((c) => [...c, part]);
|
||||
}
|
||||
result.push(...combinations);
|
||||
}
|
||||
return result.length ? result : [[]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a CEL condition token for trie key comparison.
|
||||
* Collapses whitespace around operators so "a == b" and "a==b" are the same key.
|
||||
*/
|
||||
export function normalizeCond(cond: string): string {
|
||||
return cond
|
||||
.trim()
|
||||
.replace(/\s*(==|!=|>=|<=|>|<)\s*/g, (_, op) => ` ${op} `)
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
35
ui/app/workspace/routing-rules/tree/views/constants.ts
Normal file
35
ui/app/workspace/routing-rules/tree/views/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// ─── Scope config ──────────────────────────────────────────────────────────
|
||||
|
||||
export const SCOPE_CONFIG = {
|
||||
virtual_key: { label: "Virtual Key", color: "#7c3aed", headerClass: "bg-purple-100 dark:bg-purple-900/30" },
|
||||
team: { label: "Team", color: "#2563eb", headerClass: "bg-blue-100 dark:bg-blue-900/30" },
|
||||
customer: { label: "Customer", color: "#16a34a", headerClass: "bg-green-100 dark:bg-green-900/30" },
|
||||
global: { label: "Global", color: "#6b7280", headerClass: "bg-gray-100 dark:bg-gray-800/30" },
|
||||
} as const;
|
||||
|
||||
export type ScopeKey = keyof typeof SCOPE_CONFIG;
|
||||
|
||||
export const SCOPE_ORDER = ["virtual_key", "team", "customer", "global"] as const;
|
||||
|
||||
// ─── Layout constants (LR: W = horizontal, H = vertical) ──────────────────
|
||||
|
||||
export const SRC_W = 260;
|
||||
export const SRC_H = 80;
|
||||
export const COND_W = 310;
|
||||
export const COND_H = 76;
|
||||
export const RULE_W = 220;
|
||||
export const RULE_H = 106;
|
||||
/** Baseline horizontal spacing intent (Dagre uses ranksep for rank-to-rank gaps). */
|
||||
export const H_GAP = 280;
|
||||
/** Baseline vertical spacing intent (Dagre uses nodesep within a rank). */
|
||||
export const V_GAP = 36;
|
||||
|
||||
/** Dagre: minimum horizontal gap between layers (LR ranks / columns). Higher = calmer graph. */
|
||||
export const DAGRE_RANKSEP = 300;
|
||||
/** Dagre: minimum vertical gap between nodes sharing a rank. */
|
||||
export const DAGRE_NODESEP = 52;
|
||||
/** Dagre: margin around the laid-out bounding box. */
|
||||
export const DAGRE_MARGIN = 48;
|
||||
|
||||
/** Default padding when fitting the graph to the viewport (fraction of viewport). */
|
||||
export const FIT_VIEW_PADDING = 0.14;
|
||||
487
ui/app/workspace/routing-rules/tree/views/graphBuilder.ts
Normal file
487
ui/app/workspace/routing-rules/tree/views/graphBuilder.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Converts a list of RoutingRules into a React Flow node/edge graph.
|
||||
*
|
||||
* Pipeline:
|
||||
* rules → buildTrie → mergeSubtrees → collectDAGStructure → Dagre LR layout → buildGraph
|
||||
*/
|
||||
|
||||
import { RoutingRule } from "@/lib/types/routingRules";
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { evalChainCondition, expandCEL, normalizeCond } from "./celParser";
|
||||
import {
|
||||
COND_H,
|
||||
COND_W,
|
||||
DAGRE_MARGIN,
|
||||
DAGRE_NODESEP,
|
||||
DAGRE_RANKSEP,
|
||||
RULE_H,
|
||||
RULE_W,
|
||||
SCOPE_CONFIG,
|
||||
SRC_H,
|
||||
SRC_W,
|
||||
type ScopeKey,
|
||||
} from "./constants";
|
||||
|
||||
// ─── Color mixing ──────────────────────────────────────────────────────────
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return "#" + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
/** Weighted-blend hex colours. weights default to equal if omitted. */
|
||||
function blendColors(colors: string[], weights?: number[]): string {
|
||||
if (colors.length === 1) return colors[0];
|
||||
const w = weights ?? colors.map(() => 1);
|
||||
const total = w.reduce((s, v) => s + v, 0);
|
||||
const [r, g, b] = colors
|
||||
.map((c, i) => hexToRgb(c).map((ch) => ch * (w[i] / total)) as [number, number, number])
|
||||
.reduce(([ar, ag, ab], [cr, cg, cb]) => [ar + cr, ag + cg, ab + cb], [0, 0, 0]);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// ─── Trie / DAG types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface TrieNode {
|
||||
id: string;
|
||||
condition: string | null;
|
||||
children: Map<string, TrieNode>;
|
||||
terminals: RoutingRule[];
|
||||
}
|
||||
|
||||
interface LNode {
|
||||
id: string;
|
||||
kind: "source" | "condition" | "rule" | "target";
|
||||
data: any;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
interface LEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
isChainBack?: boolean;
|
||||
isChainWeak?: boolean;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
}
|
||||
|
||||
// ─── Trie construction ────────────────────────────────────────────────────
|
||||
|
||||
export function buildTrie(rules: RoutingRule[]): TrieNode {
|
||||
let uid = 0;
|
||||
const mkNode = (c: string | null): TrieNode => ({
|
||||
id: c === null ? "root" : `n${++uid}`,
|
||||
condition: c,
|
||||
children: new Map(),
|
||||
terminals: [],
|
||||
});
|
||||
const root = mkNode(null);
|
||||
|
||||
// Pre-collect all (rule, normalized-path) pairs so we can compute frequencies.
|
||||
const allPaths: { rule: RoutingRule; path: string[] }[] = [];
|
||||
for (const rule of rules) {
|
||||
for (const path of expandCEL(rule.cel_expression ?? "")) {
|
||||
allPaths.push({ rule, path: path.map(normalizeCond) });
|
||||
}
|
||||
}
|
||||
|
||||
// Count how many paths each condition appears in.
|
||||
// Conditions shared by more paths sort earlier → maximum prefix sharing.
|
||||
const freq = new Map<string, number>();
|
||||
for (const { path } of allPaths) {
|
||||
for (const cond of new Set(path)) {
|
||||
freq.set(cond, (freq.get(cond) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into trie with paths sorted by frequency desc, then alphabetically.
|
||||
for (const { rule, path } of allPaths) {
|
||||
const sorted = [...path].sort((a, b) => {
|
||||
const d = (freq.get(b) ?? 0) - (freq.get(a) ?? 0);
|
||||
return d !== 0 ? d : a.localeCompare(b);
|
||||
});
|
||||
let node = root;
|
||||
for (const cond of sorted) {
|
||||
if (!node.children.has(cond)) node.children.set(cond, mkNode(cond));
|
||||
node = node.children.get(cond)!;
|
||||
}
|
||||
if (!node.terminals.find((r) => r.id === rule.id)) node.terminals.push(rule);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Merge structurally identical subtrees so OR-expanded duplicates share one node. */
|
||||
export function mergeSubtrees(root: TrieNode): void {
|
||||
const registry = new Map<string, TrieNode>();
|
||||
const nodeCanon = new Map<string, string>();
|
||||
|
||||
function canon(node: TrieNode, seen = new Set<string>()): string {
|
||||
if (nodeCanon.has(node.id)) return nodeCanon.get(node.id)!;
|
||||
if (seen.has(node.id)) return node.id;
|
||||
seen.add(node.id);
|
||||
const termKey = node.terminals
|
||||
.map((r) => r.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
const childKey = Array.from(node.children.entries())
|
||||
.map(([c, ch]) => `${c}:${canon(ch, new Set(seen))}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const key = `${node.condition}::${termKey}::${childKey}`;
|
||||
nodeCanon.set(node.id, key);
|
||||
if (!registry.has(key)) registry.set(key, node);
|
||||
return key;
|
||||
}
|
||||
|
||||
function postOrder(node: TrieNode, seen = new Set<string>()): void {
|
||||
if (seen.has(node.id)) return;
|
||||
seen.add(node.id);
|
||||
for (const ch of node.children.values()) postOrder(ch, seen);
|
||||
canon(node);
|
||||
}
|
||||
postOrder(root);
|
||||
|
||||
function replace(node: TrieNode, seen = new Set<string>()): void {
|
||||
if (seen.has(node.id)) return;
|
||||
seen.add(node.id);
|
||||
for (const [cond, ch] of Array.from(node.children.entries())) {
|
||||
const canonical = registry.get(nodeCanon.get(ch.id)!)!;
|
||||
if (canonical.id !== ch.id) node.children.set(cond, canonical);
|
||||
replace(canonical, seen);
|
||||
}
|
||||
}
|
||||
replace(root);
|
||||
}
|
||||
|
||||
// ─── Scope colour helpers ──────────────────────────────────────────────────
|
||||
|
||||
function collectTerminals(node: TrieNode, seen = new Set<string>()): RoutingRule[] {
|
||||
if (seen.has(node.id)) return [];
|
||||
seen.add(node.id);
|
||||
const acc = [...node.terminals];
|
||||
for (const ch of node.children.values()) acc.push(...collectTerminals(ch, seen));
|
||||
return acc;
|
||||
}
|
||||
|
||||
function nodeColor(node: TrieNode, cache?: Map<string, string | null>): string | null {
|
||||
if (cache?.has(node.id)) return cache.get(node.id)!;
|
||||
const rules = collectTerminals(node);
|
||||
if (!rules.length) {
|
||||
cache?.set(node.id, null);
|
||||
return null;
|
||||
}
|
||||
// Deduplicate by rule.id before counting — collectTerminals returns one entry
|
||||
// per OR-expanded path, so a multi-branch rule would otherwise be over-counted.
|
||||
const uniqueRules = [...new Map(rules.map((r) => [r.id, r])).values()];
|
||||
// Count rules per scope to produce a weighted blend.
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of uniqueRules) counts.set(r.scope, (counts.get(r.scope) ?? 0) + 1);
|
||||
const entries = [...counts.entries()]
|
||||
.map(([scope, count]): { color: string | undefined; count: number } => ({
|
||||
color: SCOPE_CONFIG[scope as ScopeKey]?.color,
|
||||
count,
|
||||
}))
|
||||
.filter((e): e is { color: string; count: number } => !!e.color);
|
||||
const result = entries.length
|
||||
? blendColors(
|
||||
entries.map((e) => e.color),
|
||||
entries.map((e) => e.count),
|
||||
)
|
||||
: null;
|
||||
cache?.set(node.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── DAG structure collection ─────────────────────────────────────────────
|
||||
|
||||
function collectDAGStructure(root: TrieNode): { lNodes: LNode[]; lEdges: LEdge[] } {
|
||||
const colorCache = new Map<string, string | null>();
|
||||
const lNodes: LNode[] = [{ id: "source", kind: "source", data: {}, w: SRC_W, h: SRC_H }];
|
||||
const lEdges: LEdge[] = [];
|
||||
const addedNodes = new Set<string>(["source"]);
|
||||
const addedEdges = new Set<string>();
|
||||
const processed = new Set<string>();
|
||||
const chainQueue: { ruleId: string; rule: RoutingRule; sc: string }[] = [];
|
||||
|
||||
function addEdge(src: string, tgt: string, label?: string, color?: string, opts?: Partial<LEdge>) {
|
||||
const key = `${src}→${tgt}${opts?.isChainBack ? ":chain" : ""}`;
|
||||
if (addedEdges.has(key)) return;
|
||||
addedEdges.add(key);
|
||||
lEdges.push({ source: src, target: tgt, label, color, ...opts });
|
||||
}
|
||||
|
||||
function traverse(node: TrieNode, parentId: string) {
|
||||
const isRoot = node.condition === null;
|
||||
const selfId = isRoot ? "source" : node.id;
|
||||
|
||||
if (!isRoot) {
|
||||
if (!addedNodes.has(selfId)) {
|
||||
const color = nodeColor(node, colorCache);
|
||||
const terminalRules = collectTerminals(node);
|
||||
const scopes = [...new Set(terminalRules.map((r) => r.scope))];
|
||||
lNodes.push({ id: selfId, kind: "condition", data: { condition: node.condition, color, scopes }, w: COND_W, h: COND_H });
|
||||
addedNodes.add(selfId);
|
||||
}
|
||||
addEdge(parentId, selfId, undefined, nodeColor(node, colorCache) ?? undefined);
|
||||
}
|
||||
|
||||
// Don't re-traverse a shared node's subtree from a second parent
|
||||
if (!isRoot && processed.has(selfId)) return;
|
||||
processed.add(selfId);
|
||||
|
||||
for (const ch of node.children.values()) traverse(ch, selfId);
|
||||
|
||||
for (const rule of node.terminals) {
|
||||
const ruleId = `rule-${rule.id}`;
|
||||
const sc = SCOPE_CONFIG[rule.scope as ScopeKey]?.color ?? "#9ca3af";
|
||||
if (!addedNodes.has(ruleId)) {
|
||||
lNodes.push({ id: ruleId, kind: "rule", data: { rule, scopeColor: sc }, w: RULE_W, h: RULE_H });
|
||||
addedNodes.add(ruleId);
|
||||
}
|
||||
addEdge(selfId, ruleId, undefined, sc);
|
||||
if (rule.chain_rule) chainQueue.push({ ruleId, rule, sc });
|
||||
}
|
||||
}
|
||||
|
||||
traverse(root, "");
|
||||
|
||||
// ── Second pass: chain edges to specific matching condition nodes ──────
|
||||
// For each chain rule, evaluate its resolved targets against every
|
||||
// condition node reachable from source. Connect to the first satisfied
|
||||
// condition in each path so the edge shows exactly where the chain lands.
|
||||
if (chainQueue.length > 0) {
|
||||
// Build an adjacency list (forward edges only, by definition at this point)
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const e of lEdges) {
|
||||
if (!childrenOf.has(e.source)) childrenOf.set(e.source, []);
|
||||
childrenOf.get(e.source)!.push(e.target);
|
||||
}
|
||||
const nodeById = new Map(lNodes.map((n) => [n.id, n]));
|
||||
|
||||
/** Walk forward from `startIds`, following only condition nodes, and
|
||||
* return the deepest node in each branch whose condition evaluates to
|
||||
* true for `vars`. Only emits a node if none of its condition children
|
||||
* also evaluate to true (deepest satisfied entry point semantics).
|
||||
*
|
||||
* Each result carries a `strong` flag: true when every condition on the
|
||||
* path evaluated to `true` (static chain), false when any condition was
|
||||
* `null` / too complex to evaluate (dynamic chain). */
|
||||
function findEntries(startIds: string[], vars: Record<string, string>): Array<{ id: string; strong: boolean }> {
|
||||
const results: Array<{ id: string; strong: boolean }> = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
/** Returns true if this node (or any descendant condition) matched.
|
||||
* `strong` is false once we have passed through any `null` hop. */
|
||||
function explore(id: string, strong: boolean): boolean {
|
||||
if (visited.has(id)) return false;
|
||||
visited.add(id);
|
||||
const node = nodeById.get(id);
|
||||
if (!node || node.kind !== "condition") return false;
|
||||
|
||||
const result = evalChainCondition(node.data.condition as string, vars);
|
||||
if (result === false) return false; // branch blocked
|
||||
|
||||
if (result === true) {
|
||||
// Continue into children — prefer the deepest static match
|
||||
let hasDeeper = false;
|
||||
for (const childId of childrenOf.get(id) ?? []) {
|
||||
if (explore(childId, strong)) hasDeeper = true;
|
||||
}
|
||||
if (!hasDeeper) results.push({ id, strong });
|
||||
return true;
|
||||
}
|
||||
|
||||
// result === null (too complex) — explore children but mark as weak
|
||||
let anyMatch = false;
|
||||
for (const childId of childrenOf.get(id) ?? []) {
|
||||
if (explore(childId, false)) anyMatch = true;
|
||||
}
|
||||
return anyMatch;
|
||||
}
|
||||
|
||||
for (const id of startIds) explore(id, true);
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const { ruleId, rule, sc } of chainQueue) {
|
||||
// Collect unique (provider, model) pairs across all targets
|
||||
const seen = new Set<string>();
|
||||
for (const t of rule.targets) {
|
||||
const vars: Record<string, string> = {};
|
||||
if (t.provider) vars.provider = t.provider;
|
||||
if (t.model) vars.model = t.model;
|
||||
if (!Object.keys(vars).length) {
|
||||
// passthrough target — chain loops back to source (static: we know the input is unchanged)
|
||||
addEdge(ruleId, "source", "↺", sc, { isChainBack: true, isChainWeak: false, sourceHandle: "chain-out" });
|
||||
continue;
|
||||
}
|
||||
const key = JSON.stringify(vars);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const entries = findEntries(childrenOf.get("source") ?? [], vars);
|
||||
if (entries.length === 0) {
|
||||
// resolved vars match no condition node — fall back to source
|
||||
addEdge(ruleId, "source", "↺", sc, { isChainBack: true, isChainWeak: false, sourceHandle: "chain-out" });
|
||||
}
|
||||
for (const { id: condId, strong } of entries) {
|
||||
addEdge(ruleId, condId, "↺", sc, { isChainBack: true, isChainWeak: !strong, sourceHandle: "chain-out" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { lNodes, lEdges };
|
||||
}
|
||||
|
||||
// ─── Dagre layered layout (LR) ───────────────────────────────────────────
|
||||
//
|
||||
// Uses @dagrejs/dagre with the network-simplex ranker for crossing reduction and
|
||||
// consistent rank spacing. Chain-back edges are excluded — they are drawn after.
|
||||
|
||||
function computeLRLayout(lNodes: LNode[], lEdges: LEdge[]): Map<string, { x: number; y: number }> {
|
||||
const g = new dagre.graphlib.Graph({ multigraph: false });
|
||||
g.setGraph({
|
||||
rankdir: "LR",
|
||||
// Network-simplex tends to produce cleaner orderings than longest-path on DAGs.
|
||||
ranker: "network-simplex",
|
||||
ranksep: DAGRE_RANKSEP,
|
||||
nodesep: DAGRE_NODESEP,
|
||||
edgesep: 16,
|
||||
marginx: DAGRE_MARGIN,
|
||||
marginy: DAGRE_MARGIN,
|
||||
align: "UL",
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const n of lNodes) {
|
||||
g.setNode(n.id, { width: n.w, height: n.h });
|
||||
}
|
||||
|
||||
const forward = lEdges.filter((e) => !e.isChainBack);
|
||||
const edgeKey = new Set<string>();
|
||||
for (const e of forward) {
|
||||
const k = `${e.source}\0${e.target}`;
|
||||
if (edgeKey.has(k)) continue;
|
||||
edgeKey.add(k);
|
||||
if (g.hasNode(e.source) && g.hasNode(e.target)) {
|
||||
g.setEdge(e.source, e.target);
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const n of lNodes) {
|
||||
const laid = g.node(n.id);
|
||||
if (laid === undefined) continue;
|
||||
// Dagre uses x,y as the centre of each node.
|
||||
positions.set(n.id, {
|
||||
x: laid.x - n.w / 2,
|
||||
y: laid.y - n.h / 2,
|
||||
});
|
||||
}
|
||||
|
||||
// Dagre pins the first rank to the top of the layout; shift "source" so its
|
||||
// vertical centre matches the midpoint of every *other* node's bounding box.
|
||||
centerSourceVertically(positions, lNodes);
|
||||
|
||||
// Pull the source node an extra gap to the left so it visually breathes
|
||||
// away from the first condition column.
|
||||
const sourcePos = positions.get("source");
|
||||
if (sourcePos) sourcePos.x -= 200;
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** Move the source node so it sits at the vertical centre of the rest of the graph. */
|
||||
function centerSourceVertically(positions: Map<string, { x: number; y: number }>, lNodes: LNode[]): void {
|
||||
const sourceEntry = lNodes.find((n) => n.id === "source");
|
||||
const sourcePos = positions.get("source");
|
||||
if (!sourceEntry || !sourcePos) return;
|
||||
|
||||
const others = lNodes.filter((n) => n.id !== "source");
|
||||
if (others.length === 0) return;
|
||||
|
||||
let minTop = Infinity;
|
||||
let maxBottom = -Infinity;
|
||||
for (const n of others) {
|
||||
const p = positions.get(n.id);
|
||||
if (!p) continue;
|
||||
minTop = Math.min(minTop, p.y);
|
||||
maxBottom = Math.max(maxBottom, p.y + n.h);
|
||||
}
|
||||
if (!Number.isFinite(minTop) || !Number.isFinite(maxBottom)) return;
|
||||
|
||||
const midY = (minTop + maxBottom) / 2;
|
||||
sourcePos.y = midY - sourceEntry.h / 2;
|
||||
}
|
||||
|
||||
// ─── Build React Flow graph ────────────────────────────────────────────────
|
||||
|
||||
export function buildGraph(rules: RoutingRule[]): { nodes: Node[]; edges: Edge[] } {
|
||||
const trie = buildTrie(rules);
|
||||
mergeSubtrees(trie);
|
||||
const { lNodes, lEdges } = collectDAGStructure(trie);
|
||||
// Chain-back edges form cycles — exclude them from layout (forward edges only).
|
||||
const positions = computeLRLayout(
|
||||
lNodes,
|
||||
lEdges.filter((e) => !e.isChainBack),
|
||||
);
|
||||
|
||||
const kindType: Record<string, string> = {
|
||||
source: "rfSource",
|
||||
condition: "rfCondition",
|
||||
rule: "rfRule",
|
||||
};
|
||||
|
||||
const rfNodes: Node[] = lNodes.map((ln) => ({
|
||||
id: ln.id,
|
||||
type: kindType[ln.kind],
|
||||
position: positions.get(ln.id) ?? { x: 0, y: 0 },
|
||||
data: ln.data,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
connectable: false,
|
||||
}));
|
||||
|
||||
const rfEdges: Edge[] = lEdges.map((le) => {
|
||||
const base = {
|
||||
id: `e-${le.source}-${le.target}${le.isChainBack ? "-chain" : ""}`,
|
||||
source: le.source,
|
||||
target: le.target,
|
||||
...(le.sourceHandle ? { sourceHandle: le.sourceHandle } : {}),
|
||||
...(le.targetHandle ? { targetHandle: le.targetHandle } : {}),
|
||||
};
|
||||
if (le.isChainBack) {
|
||||
// Both dashed: longer dashes (static) vs shorter dashes (dynamic). Mid-arrow in rfChainEdge.
|
||||
const weak = le.isChainWeak;
|
||||
return {
|
||||
...base,
|
||||
type: "rfChain",
|
||||
data: { chainWeak: weak },
|
||||
animated: false,
|
||||
...(weak ? { className: "rf-chain-edge-dynamic" } : {}),
|
||||
style: {
|
||||
stroke: le.color,
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: "round",
|
||||
...(weak ? {} : { strokeDasharray: "14 10" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: "simplebezier",
|
||||
style: { stroke: le.color ?? "var(--border)", strokeWidth: le.color ? 1.5 : 1 },
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: rfNodes, edges: rfEdges };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Position } from "@xyflow/react";
|
||||
import { COND_H, COND_W, SCOPE_CONFIG, type ScopeKey } from "../constants";
|
||||
import { RFEdgeHandle } from "./rfEdgeHandle";
|
||||
|
||||
/** Width of the left scope-color strip (matches common “start node” accent bars). */
|
||||
const ACCENT_STRIP_CLASS = "w-2.5";
|
||||
|
||||
export function RFConditionNode({ data }: { data: any }) {
|
||||
const condition = data.condition as string;
|
||||
const color = data.color as string | null;
|
||||
const scopes = (data.scopes as string[] | undefined) ?? [];
|
||||
const accent = color ?? undefined;
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: COND_W, minHeight: COND_H }}>
|
||||
<RFEdgeHandle type="target" position={Position.Left} accentColor={accent ?? "var(--muted-foreground)"} />
|
||||
<RFEdgeHandle type="source" position={Position.Right} accentColor={accent ?? "var(--muted-foreground)"} />
|
||||
<div
|
||||
className="dark:bg-card relative z-10 flex w-full cursor-grab overflow-hidden rounded-lg border bg-white shadow-sm active:cursor-grabbing"
|
||||
style={{ minHeight: COND_H, borderColor: accent }}
|
||||
>
|
||||
<div
|
||||
className={cn("shrink-0 self-stretch", ACCENT_STRIP_CLASS, !accent && "bg-muted")}
|
||||
style={accent ? { backgroundColor: accent } : undefined}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4 px-3 py-2.5">
|
||||
<code className="text-foreground flex-1 font-mono text-[12px] leading-snug break-all">{condition}</code>
|
||||
{scopes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{scopes.map((sc) => {
|
||||
const cfg = SCOPE_CONFIG[sc as ScopeKey];
|
||||
return cfg ? (
|
||||
<span
|
||||
key={sc}
|
||||
className="rounded px-1 py-0 text-[9px] font-semibold"
|
||||
style={{ backgroundColor: `${cfg.color}18`, color: cfg.color }}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, type HandleProps } from "@xyflow/react";
|
||||
|
||||
/** Visual diameter; React Flow’s default is ~6px — larger so half the disc reads clearly past the node edge. */
|
||||
export const RF_HANDLE_SIZE_PX = 14;
|
||||
|
||||
export type RFEdgeHandleProps = Omit<HandleProps, "className"> & {
|
||||
className?: string;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
export function RFEdgeHandle({ className, accentColor, style, ...rest }: RFEdgeHandleProps) {
|
||||
return (
|
||||
<Handle
|
||||
className={cn(
|
||||
"!pointer-events-auto !z-0",
|
||||
"!h-[14px] !min-h-[14px] !w-[14px] !min-w-[14px]",
|
||||
"!rounded-full !border-0 !border-none !p-0 !shadow-none",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
...(accentColor ? { background: accentColor } : {}),
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
144
ui/app/workspace/routing-rules/tree/views/node/rfRuleNode.tsx
Normal file
144
ui/app/workspace/routing-rules/tree/views/node/rfRuleNode.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import { RoutingRule } from "@/lib/types/routingRules";
|
||||
import { Position } from "@xyflow/react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { RULE_W, SCOPE_CONFIG, type ScopeKey } from "../constants";
|
||||
import { RFEdgeHandle } from "./rfEdgeHandle";
|
||||
|
||||
export function RFRuleNode({ data }: { data: any }) {
|
||||
const rule = data.rule as RoutingRule;
|
||||
const scopeColor = data.scopeColor as string;
|
||||
const cfg = SCOPE_CONFIG[rule.scope as ScopeKey];
|
||||
const multi = rule.targets.length > 1;
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: RULE_W }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={hovered}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setHovered(true)}
|
||||
onBlur={() => setHovered(false)}
|
||||
onClick={() => setHovered((v) => !v)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setHovered((v) => !v);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RFEdgeHandle type="target" position={Position.Left} accentColor={scopeColor} />
|
||||
{rule.chain_rule && <RFEdgeHandle type="source" id="chain-out" position={Position.Right} accentColor={scopeColor} />}
|
||||
<div
|
||||
className="dark:bg-card relative z-10 cursor-grab rounded-lg border-2 bg-white shadow-sm active:cursor-grabbing"
|
||||
style={{ borderColor: scopeColor, borderStyle: rule.chain_rule ? "dashed" : "solid" }}
|
||||
>
|
||||
{/* scope header */}
|
||||
<div className={`flex items-center gap-1.5 rounded-t-[6px] px-3 py-1.5 ${cfg?.headerClass ?? "bg-gray-100 dark:bg-gray-800/30"}`}>
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full" style={{ backgroundColor: scopeColor }} />
|
||||
<span className="text-[10px] font-semibold" style={{ color: scopeColor }}>
|
||||
{cfg?.label ?? rule.scope}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{rule.chain_rule && <Link2 className="h-3 w-3" style={{ color: scopeColor }} />}
|
||||
{!rule.enabled && (
|
||||
<Badge variant="secondary" className="px-1 py-0 text-[9px]">
|
||||
Off
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* rule name */}
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-foreground truncate text-xs font-semibold">{rule.name}</p>
|
||||
{rule.priority > 0 && <p className="text-muted-foreground mt-0.5 text-[10px]">Priority {rule.priority}</p>}
|
||||
</div>
|
||||
|
||||
{/* targets footer */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-b-[6px] border-t px-3 py-1.5"
|
||||
style={{ borderColor: `${scopeColor}40`, backgroundColor: `${scopeColor}08` }}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{rule.targets
|
||||
.slice(0, 4)
|
||||
.map((t, i) =>
|
||||
t.provider ? (
|
||||
<RenderProviderIcon key={i} provider={t.provider as ProviderIconType} size={12} />
|
||||
) : (
|
||||
<span key={i} className="bg-muted-foreground/30 h-2 w-2 rounded-full" />
|
||||
),
|
||||
)}
|
||||
{rule.targets.length > 4 && <span className="text-muted-foreground text-[9px]">+{rule.targets.length - 4}</span>}
|
||||
</div>
|
||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||
{rule.targets.length} target{rule.targets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* hover popover */}
|
||||
{hovered && (
|
||||
<div
|
||||
className="nodrag nowheel dark:bg-card absolute top-0 left-full z-50 ml-3 min-w-[190px] rounded-lg border-2 bg-white py-1.5 shadow-xl"
|
||||
style={{ borderColor: scopeColor }}
|
||||
>
|
||||
{rule.scope !== "global" && rule.scope_id && (
|
||||
<div className="mb-1 border-b px-3 pb-1.5">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
<span className="font-semibold" style={{ color: scopeColor }}>
|
||||
{cfg?.label ?? rule.scope}:{" "}
|
||||
</span>
|
||||
<span className="text-foreground font-medium">{rule.scope_id}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{rule.chain_rule && (
|
||||
<div className="mb-1 flex items-start gap-2 border-b px-3 pb-1.5">
|
||||
<Link2 className="mt-0.5 h-3 w-3 shrink-0" style={{ color: scopeColor }} />
|
||||
<p className="text-muted-foreground text-[10px] leading-snug">
|
||||
Chain rule — resolved provider/model feeds back as the new input and the full scope chain re-evaluates.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="mb-1 px-3 text-[10px] font-semibold tracking-wide uppercase" style={{ color: scopeColor }}>
|
||||
{rule.chain_rule ? "Resolved target (new input)" : "Targets"}
|
||||
</p>
|
||||
{rule.targets.map((t, i) => {
|
||||
const isPassthrough = !t.provider && !t.model;
|
||||
return (
|
||||
<div key={i} className="hover:bg-muted flex items-center gap-2 px-3 py-1.5">
|
||||
{t.provider ? (
|
||||
<RenderProviderIcon provider={t.provider as ProviderIconType} size={13} />
|
||||
) : (
|
||||
<span className="bg-muted-foreground/30 h-3 w-3 flex-shrink-0 rounded-full" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-xs font-medium">
|
||||
{isPassthrough ? "Passthrough" : t.provider ? getProviderLabel(t.provider) : t.model}
|
||||
</p>
|
||||
{t.model && t.provider && <p className="text-muted-foreground truncate font-mono text-[10px]">{t.model}</p>}
|
||||
{isPassthrough && <p className="text-muted-foreground/60 text-[10px] italic">original provider & model</p>}
|
||||
</div>
|
||||
{multi && (
|
||||
<span className="ml-1 shrink-0 text-[11px] font-semibold" style={{ color: scopeColor }}>
|
||||
{Math.round(t.weight * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Position } from "@xyflow/react";
|
||||
import { Network } from "lucide-react";
|
||||
import { SRC_H, SRC_W } from "../constants";
|
||||
import { RFEdgeHandle } from "./rfEdgeHandle";
|
||||
|
||||
export function RFSourceNode() {
|
||||
return (
|
||||
<div className="relative" style={{ width: SRC_W, height: SRC_H }}>
|
||||
<RFEdgeHandle type="source" position={Position.Right} accentColor="var(--primary)" />
|
||||
<div className="border-primary dark:bg-card relative z-10 flex h-full cursor-grab flex-col justify-center rounded-xl border-2 bg-white px-5 shadow-md active:cursor-grabbing">
|
||||
<div className="text-foreground flex items-center gap-2 font-semibold">
|
||||
<Network className="text-primary h-4 w-4" />
|
||||
Incoming Request
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-[11px]">provider · model · headers · params · limits</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { RoutingRule } from "@/lib/types/routingRules";
|
||||
|
||||
export const POSITIONS_COOKIE = "bf-routing-tree-positions";
|
||||
|
||||
export interface PositionCookie {
|
||||
fingerprint: string;
|
||||
positions: Record<string, { x: number; y: number }>;
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
}
|
||||
|
||||
/** Changes whenever any rule is added, edited, or deleted. */
|
||||
export function computeFingerprint(rules: RoutingRule[]): string {
|
||||
return rules
|
||||
.map((r) => `${r.id}:${r.updated_at}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
81
ui/app/workspace/routing-rules/tree/views/rfChainEdge.tsx
Normal file
81
ui/app/workspace/routing-rules/tree/views/rfChainEdge.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { BaseEdge, type EdgeProps } from "@xyflow/react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
export type RfChainEdgeData = {
|
||||
/** When true, the chain is "dynamic" — static analysis could not fully prove the re-entry path. */
|
||||
chainWeak?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a cubic Bézier path for a chain-back edge.
|
||||
*
|
||||
* Because the source handle exits to the RIGHT and the target is often to the
|
||||
* LEFT (the edge loops back), the standard `getSimpleBezierPath` control-point
|
||||
* formula places both control points between source and target, causing the
|
||||
* edge to immediately swing left from a right-facing handle.
|
||||
*
|
||||
* Instead we always push cp1 rightward past the source and cp2 leftward past
|
||||
* the target, creating a clear outward loop before the edge arrives at the
|
||||
* condition node's left handle.
|
||||
*/
|
||||
function buildChainPath(
|
||||
sx: number,
|
||||
sy: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
): { path: string; labelX: number; labelY: number; angleDeg: number } {
|
||||
// Offset scales with horizontal distance so the loop expands when nodes
|
||||
// are far apart, but stays legible when they are close.
|
||||
const hDist = Math.abs(sx - tx);
|
||||
const offset = Math.max(80, hDist * 0.25);
|
||||
|
||||
const cp1x = sx + offset; // always to the right of the source
|
||||
const cp1y = sy;
|
||||
const cp2x = tx - offset; // always to the left of the target
|
||||
const cp2y = ty;
|
||||
|
||||
const path = `M${sx},${sy} C${cp1x},${cp1y} ${cp2x},${cp2y} ${tx},${ty}`;
|
||||
|
||||
// Midpoint of cubic Bézier at t = 0.5
|
||||
const labelX = sx / 8 + (3 * cp1x) / 8 + (3 * cp2x) / 8 + tx / 8;
|
||||
const labelY = sy / 8 + (3 * cp1y) / 8 + (3 * cp2y) / 8 + ty / 8;
|
||||
|
||||
// Tangent direction at t = 0.5 for the mid-edge arrow orientation
|
||||
const dx = 0.75 * (cp1x - sx) + 1.5 * (cp2x - cp1x) + 0.75 * (tx - cp2x);
|
||||
const dy = 0.75 * (cp1y - sy) + 1.5 * (cp2y - cp1y) + 0.75 * (ty - cp2y);
|
||||
const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
|
||||
return { path, labelX, labelY, angleDeg };
|
||||
}
|
||||
|
||||
function RfChainEdgeImpl({ id, sourceX, sourceY, targetX, targetY, style, interactionWidth, data }: EdgeProps) {
|
||||
const weak = (data as RfChainEdgeData | undefined)?.chainWeak === true;
|
||||
const strokeColor = typeof style?.stroke === "string" && style.stroke.length > 0 ? style.stroke : "var(--foreground)";
|
||||
|
||||
const { path, labelX, labelY, angleDeg } = useMemo(
|
||||
() => buildChainPath(sourceX, sourceY, targetX, targetY),
|
||||
[sourceX, sourceY, targetX, targetY],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={path} style={style} interactionWidth={interactionWidth ?? 12} />
|
||||
<g transform={`translate(${labelX}, ${labelY}) rotate(${angleDeg})`} className="pointer-events-none" aria-hidden>
|
||||
{weak ? (
|
||||
<polyline
|
||||
points="-6,-4 6,0 -6,4"
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
) : (
|
||||
<polygon points="8,0 -6,-5 -6,5" fill={strokeColor} />
|
||||
)}
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const RfChainEdge = memo(RfChainEdgeImpl);
|
||||
536
ui/app/workspace/routing-rules/tree/views/routingTreeView.tsx
Normal file
536
ui/app/workspace/routing-rules/tree/views/routingTreeView.tsx
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Routing Tree View — left-to-right flow lane.
|
||||
*
|
||||
* Source → conditions (shared prefix / OR merge) → rule node → target node(s)
|
||||
*
|
||||
* OR branches are split into parallel paths that converge on the same
|
||||
* shared child via subtree-hash deduplication. Each rule target gets its
|
||||
* own leaf node. Nodes are draggable for exploration; nothing is editable.
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useGetRoutingRulesQuery } from "@/lib/store/apis/routingRulesApi";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import type { Node, NodeChange } from "@xyflow/react";
|
||||
import { Background, BackgroundVariant, Controls, Panel, ReactFlow, useEdgesState, useNodesState } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { AlertCircle, ArrowLeft, GitBranch, Info, Link2, Loader2, RotateCcw, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCookies } from "react-cookie";
|
||||
import { FIT_VIEW_PADDING, SCOPE_CONFIG, SCOPE_ORDER } from "./constants";
|
||||
import { buildGraph } from "./graphBuilder";
|
||||
import { RFConditionNode } from "./node/rfConditionNode";
|
||||
import { RFRuleNode } from "./node/rfRuleNode";
|
||||
import { RFSourceNode } from "./node/rfSourceNode";
|
||||
import { POSITIONS_COOKIE, PositionCookie, computeFingerprint } from "./positionPersistence";
|
||||
import { RfChainEdge } from "./rfChainEdge";
|
||||
|
||||
// ─── Node types (stable reference) ────────────────────────────────────────
|
||||
|
||||
const nodeTypes = {
|
||||
rfSource: RFSourceNode,
|
||||
rfCondition: RFConditionNode,
|
||||
rfRule: RFRuleNode,
|
||||
};
|
||||
|
||||
const edgeTypes = { rfChain: RfChainEdge };
|
||||
|
||||
// ─── Main component ────────────────────────────────────────────────────────
|
||||
|
||||
export function RoutingTreeView() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError } = useGetRoutingRulesQuery({ limit: 500 });
|
||||
const rules = data?.rules ?? [];
|
||||
|
||||
// ── Position persistence ───────────────────────────────────────────────
|
||||
const [cookies, setCookie, removeCookie] = useCookies([POSITIONS_COOKIE]);
|
||||
|
||||
// React Flow instance — captured via onInit so we can call fitView imperatively.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rfInstanceRef = useRef<any>(null);
|
||||
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Capture cookie value once on mount so re-saves don't trigger re-renders.
|
||||
const [initialCookie] = useState<PositionCookie | undefined>(() => cookies[POSITIONS_COOKIE] as PositionCookie | undefined);
|
||||
|
||||
const fingerprint = useMemo(() => computeFingerprint(rules), [rules]);
|
||||
|
||||
const { baseNodes, baseEdges } = useMemo(() => {
|
||||
const g = buildGraph(rules);
|
||||
return { baseNodes: g.nodes, baseEdges: g.edges };
|
||||
}, [rules]);
|
||||
|
||||
// If the cookie fingerprint matches current rules, restore saved positions.
|
||||
const { mergedNodes, positionsRestored } = useMemo(() => {
|
||||
if (initialCookie?.fingerprint === fingerprint && initialCookie?.positions && Object.keys(initialCookie.positions).length > 0) {
|
||||
return {
|
||||
mergedNodes: baseNodes.map((n) => ({
|
||||
...n,
|
||||
position: initialCookie.positions[n.id] ?? n.position,
|
||||
})),
|
||||
positionsRestored: true,
|
||||
};
|
||||
}
|
||||
return { mergedNodes: baseNodes, positionsRestored: false };
|
||||
}, [baseNodes, fingerprint, initialCookie]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(mergedNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(baseEdges);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(mergedNodes);
|
||||
}, [mergedNodes, setNodes]);
|
||||
useEffect(() => {
|
||||
setEdges(baseEdges);
|
||||
}, [baseEdges, setEdges]);
|
||||
|
||||
// Always reflect the latest nodes in a ref so the save handler is not stale.
|
||||
const nodesRef = useRef(nodes);
|
||||
nodesRef.current = nodes;
|
||||
|
||||
// Tracks the last data written so position-save and viewport-save don't clobber each other.
|
||||
const cookieDataRef = useRef<Omit<PositionCookie, "fingerprint">>({ positions: {}, viewport: undefined });
|
||||
|
||||
// Once positions are known to be restored, seed the ref so viewport-only saves keep positions.
|
||||
useEffect(() => {
|
||||
if (positionsRestored && initialCookie) {
|
||||
cookieDataRef.current = { positions: initialCookie.positions, viewport: initialCookie.viewport };
|
||||
}
|
||||
}, [positionsRestored, initialCookie]);
|
||||
|
||||
const writeCookie = useCallback(
|
||||
(update: Partial<Omit<PositionCookie, "fingerprint">>) => {
|
||||
cookieDataRef.current = { ...cookieDataRef.current, ...update };
|
||||
setCookie(POSITIONS_COOKIE, { fingerprint, ...cookieDataRef.current } satisfies PositionCookie, {
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
});
|
||||
},
|
||||
[fingerprint, setCookie],
|
||||
);
|
||||
|
||||
// Save positions to cookie when a drag ends.
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
onNodesChange(changes);
|
||||
const hasDragEnd = changes.some((c) => c.type === "position" && c.dragging === false);
|
||||
if (!hasDragEnd) return;
|
||||
|
||||
const posMap: Record<string, { x: number; y: number }> = {};
|
||||
for (const n of nodesRef.current) posMap[n.id] = n.position;
|
||||
// Apply the final positions from the change events themselves (state not yet flushed).
|
||||
for (const c of changes) {
|
||||
if (c.type === "position" && c.dragging === false && c.position) {
|
||||
posMap[c.id] = c.position;
|
||||
}
|
||||
}
|
||||
writeCookie({ positions: posMap });
|
||||
},
|
||||
[onNodesChange, writeCookie],
|
||||
);
|
||||
|
||||
// Save viewport (pan + zoom) when the user stops moving.
|
||||
const handleMoveEnd = useCallback(
|
||||
(_: unknown, viewport: { x: number; y: number; zoom: number }) => {
|
||||
writeCookie({ viewport });
|
||||
},
|
||||
[writeCookie],
|
||||
);
|
||||
|
||||
// Reset all saved positions and re-fit to the computed default layout.
|
||||
const handleResetLayout = useCallback(() => {
|
||||
removeCookie(POSITIONS_COOKIE, { path: "/" });
|
||||
cookieDataRef.current = { positions: {}, viewport: undefined };
|
||||
setNodes(baseNodes);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
|
||||
resetTimeoutRef.current = setTimeout(() => rfInstanceRef.current?.fitView({ padding: FIT_VIEW_PADDING, duration: 300 }), 50);
|
||||
}, [baseNodes, removeCookie, setNodes]);
|
||||
|
||||
// ── Selection / path highlight ────────────────────────────────────────
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* BFS up (forward edges only — skips chain-backs) to find all ancestors,
|
||||
* and BFS down (all edges including chain-backs) to find all descendants.
|
||||
* Chain-back edges are identified by their id ending in "-chain".
|
||||
*/
|
||||
const selectedHighlightIds = useMemo<Set<string> | null>(() => {
|
||||
// Edge selection: highlight only the two endpoint nodes + the edge itself.
|
||||
if (selectedEdgeId) {
|
||||
const edge = edges.find((e) => e.id === selectedEdgeId);
|
||||
if (!edge) return null;
|
||||
return new Set<string>([edge.source, edge.target]);
|
||||
}
|
||||
|
||||
if (!selectedNodeId) return null;
|
||||
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
const parentsOf = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!childrenOf.has(e.source)) childrenOf.set(e.source, []);
|
||||
childrenOf.get(e.source)!.push(e.target);
|
||||
// Exclude chain-back edges from the ancestor map (they reverse flow direction).
|
||||
if (!e.id.endsWith("-chain")) {
|
||||
if (!parentsOf.has(e.target)) parentsOf.set(e.target, []);
|
||||
parentsOf.get(e.target)!.push(e.source);
|
||||
}
|
||||
}
|
||||
|
||||
const highlighted = new Set<string>([selectedNodeId]);
|
||||
|
||||
const upQ = [selectedNodeId];
|
||||
while (upQ.length) {
|
||||
const id = upQ.pop()!;
|
||||
for (const p of parentsOf.get(id) ?? []) {
|
||||
if (!highlighted.has(p)) {
|
||||
highlighted.add(p);
|
||||
upQ.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downQ = [selectedNodeId];
|
||||
while (downQ.length) {
|
||||
const id = downQ.pop()!;
|
||||
for (const c of childrenOf.get(id) ?? []) {
|
||||
if (!highlighted.has(c)) {
|
||||
highlighted.add(c);
|
||||
downQ.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return highlighted;
|
||||
}, [selectedNodeId, selectedEdgeId, edges]);
|
||||
|
||||
const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedNodeId((prev) => (prev === node.id ? null : node.id));
|
||||
}, []);
|
||||
|
||||
const handleEdgeClick = useCallback((_: React.MouseEvent, edge: { id: string }) => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId((prev) => (prev === edge.id ? null : edge.id));
|
||||
}, []);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}, []);
|
||||
|
||||
// ── Search / highlight ─────────────────────────────────────────────────
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
/**
|
||||
* Returns null when search is empty (no filtering).
|
||||
* Returns an empty Set when there are no matches (dim everything).
|
||||
* Otherwise returns the set of node IDs that should stay visible:
|
||||
* directly matching nodes + all their ancestors + all their descendants.
|
||||
*/
|
||||
const highlightedIds = useMemo<Set<string> | null>(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
const parentsOf = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!childrenOf.has(e.source)) childrenOf.set(e.source, []);
|
||||
childrenOf.get(e.source)!.push(e.target);
|
||||
if (!parentsOf.has(e.target)) parentsOf.set(e.target, []);
|
||||
parentsOf.get(e.target)!.push(e.source);
|
||||
}
|
||||
|
||||
const matched = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
const d = n.data as any;
|
||||
const cond = (d?.condition as string | undefined)?.toLowerCase();
|
||||
const ruleName = (d?.rule?.name as string | undefined)?.toLowerCase();
|
||||
const ruleCel = (d?.rule?.cel_expression as string | undefined)?.toLowerCase();
|
||||
if (cond?.includes(q) || ruleName?.includes(q) || ruleCel?.includes(q)) {
|
||||
matched.add(n.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (matched.size === 0) return new Set();
|
||||
|
||||
const highlighted = new Set<string>(matched);
|
||||
|
||||
// BFS upstream → source
|
||||
const upQ = [...matched];
|
||||
while (upQ.length) {
|
||||
const id = upQ.pop()!;
|
||||
for (const p of parentsOf.get(id) ?? []) {
|
||||
if (!highlighted.has(p)) {
|
||||
highlighted.add(p);
|
||||
upQ.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BFS downstream → rule leaves
|
||||
const downQ = [...matched];
|
||||
while (downQ.length) {
|
||||
const id = downQ.pop()!;
|
||||
for (const c of childrenOf.get(id) ?? []) {
|
||||
if (!highlighted.has(c)) {
|
||||
highlighted.add(c);
|
||||
downQ.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return highlighted;
|
||||
}, [search, nodes, edges]);
|
||||
|
||||
const matchCount = useMemo(() => {
|
||||
if (!highlightedIds) return 0;
|
||||
return nodes.filter((n) => n.type === "rfRule" && highlightedIds.has(n.id)).length;
|
||||
}, [highlightedIds, nodes]);
|
||||
|
||||
// Selection takes priority; search acts as fallback when nothing is selected.
|
||||
const activeHighlightIds = selectedHighlightIds ?? highlightedIds;
|
||||
|
||||
// Derived display nodes/edges — keeps opacity layered on top without
|
||||
// disturbing drag state (positions stay in the underlying `nodes` state).
|
||||
const displayNodes = useMemo(() => {
|
||||
const h = activeHighlightIds;
|
||||
const dimOpacity = selectedNodeId ? 0.12 : 0.25;
|
||||
return nodes.map((n) => {
|
||||
const isSelected = n.id === selectedNodeId;
|
||||
if (!h) return { ...n, selected: isSelected };
|
||||
const active = h.size > 0;
|
||||
return {
|
||||
...n,
|
||||
selected: isSelected,
|
||||
style: {
|
||||
...n.style,
|
||||
opacity: active && !h.has(n.id) ? dimOpacity : 1,
|
||||
transition: "opacity 0.15s",
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [nodes, activeHighlightIds, selectedNodeId]);
|
||||
|
||||
const displayEdges = useMemo(() => {
|
||||
const h = activeHighlightIds;
|
||||
const dimOpacity = selectedNodeId || selectedEdgeId ? 0.1 : 0.2;
|
||||
if (!h) return edges;
|
||||
const active = h.size > 0;
|
||||
return edges.map((e) => {
|
||||
const endpointsLit = h.has(e.source) && h.has(e.target);
|
||||
const isSelectedEdge = e.id === selectedEdgeId;
|
||||
const lit = endpointsLit || isSelectedEdge;
|
||||
return {
|
||||
...e,
|
||||
style: {
|
||||
...e.style,
|
||||
opacity: active && !lit ? dimOpacity : 1,
|
||||
transition: "opacity 0.15s",
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [edges, activeHighlightIds, selectedNodeId, selectedEdgeId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center gap-2">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<span className="text-sm">Failed to load routing rules</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3">
|
||||
<GitBranch className="h-10 w-10 opacity-20" />
|
||||
<p className="text-sm">No routing rules to display</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="routing-tree-back-empty-btn"
|
||||
onClick={() => navigate({ to: "/workspace/routing-rules" })}
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" />
|
||||
Back to rules
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={displayNodes}
|
||||
edges={displayEdges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
onInit={(instance) => {
|
||||
rfInstanceRef.current = instance;
|
||||
}}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView={!positionsRestored}
|
||||
fitViewOptions={{ padding: FIT_VIEW_PADDING }}
|
||||
defaultViewport={positionsRestored ? (initialCookie?.viewport ?? { x: 0, y: 0, zoom: 1 }) : undefined}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
nodesDraggable={true}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={true}
|
||||
zoomOnDoubleClick={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="var(--border)" />
|
||||
<Controls showInteractive={false} />
|
||||
|
||||
<Panel position="top-left">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Main toolbar */}
|
||||
<div className="dark:bg-card flex items-center gap-3 rounded-md border bg-white px-4 py-2.5 shadow-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-1 gap-1.5 !pl-0 hover:bg-transparent"
|
||||
data-testid="routing-tree-back-toolbar-btn"
|
||||
onClick={() => navigate({ to: "/workspace/routing-rules" })}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="bg-border h-5 w-px" />
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="text-muted-foreground h-4 w-4" />
|
||||
<p className="text-foreground text-sm leading-tight font-semibold">Routing Tree</p>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{search
|
||||
? highlightedIds && highlightedIds.size > 0
|
||||
? `${matchCount} rule${matchCount !== 1 ? "s" : ""}`
|
||||
: "no match"
|
||||
: `${rules.length} rule${rules.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-border h-5 w-px" />
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search conditions or rules…"
|
||||
className="h-8 w-56 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-border h-5 w-px" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground gap-1.5"
|
||||
onClick={handleResetLayout}
|
||||
title="Reset to default layout"
|
||||
data-testid="routing-tree-reset-layout-btn"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reset layout
|
||||
</Button>
|
||||
</div>
|
||||
{/* Scope + edge legend — floats below */}
|
||||
<div className="dark:bg-card flex items-center gap-3 rounded-md border bg-white px-3 py-1.5 shadow-sm">
|
||||
{SCOPE_ORDER.map((s) => (
|
||||
<div key={s} className="flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: SCOPE_CONFIG[s].color }} />
|
||||
<span className="text-muted-foreground text-[10px] font-medium">{SCOPE_CONFIG[s].label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="bg-border h-3 w-px" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="text-muted-foreground h-2.5 w-2.5" />
|
||||
<span className="text-muted-foreground text-[10px] font-medium">Chain rule</span>
|
||||
</div>
|
||||
<div className="bg-border h-3 w-px" />
|
||||
{/* Chain edge styles — both dashed (long = static, short = dynamic); arrows at path midpoint */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width="40" height="12" className="shrink-0" aria-hidden>
|
||||
<line
|
||||
x1="2"
|
||||
y1="6"
|
||||
x2="38"
|
||||
y2="6"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="14 10"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<polygon points="20,6 14,2.5 14,9.5" fill="var(--muted-foreground)" />
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-[10px] font-medium">Static chain</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info
|
||||
className="text-muted-foreground/60 h-2.5 w-2.5 cursor-default"
|
||||
data-testid="routing-tree-static-chain-info-trigger"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[200px] text-center">
|
||||
Re-entry point is fully proven by static analysis — every condition on the path evaluated to a known value.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width="40" height="12" className="shrink-0 overflow-visible" aria-hidden>
|
||||
<line
|
||||
className="rf-chain-legend-dynamic-dash"
|
||||
x1="2"
|
||||
y1="6"
|
||||
x2="38"
|
||||
y2="6"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3 5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<polyline
|
||||
points="14,2.5 26,6 14,9.5"
|
||||
fill="none"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-muted-foreground text-[10px] font-medium">Dynamic chain</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info
|
||||
className="text-muted-foreground/60 h-2.5 w-2.5 cursor-default"
|
||||
data-testid="routing-tree-dynamic-chain-info-trigger"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[200px] text-center">
|
||||
Re-entry point is a conditional — one or more conditions on the path are not fully evaluated at build time.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
358
ui/app/workspace/routing-rules/views/routingRuleInfoSheet.tsx
Normal file
358
ui/app/workspace/routing-rules/views/routingRuleInfoSheet.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DottedSeparator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { baseRoutingFields } from "@/lib/config/celFieldsRouting";
|
||||
import { getOperatorLabel } from "@/lib/config/celOperatorsRouting";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import { useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store/apis/governanceApi";
|
||||
import { RoutingRule } from "@/lib/types/routingRules";
|
||||
import { getScopeLabel } from "@/lib/utils/routingRules";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Check, Copy, GitMerge, Key } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RuleGroupType, RuleType } from "react-querybuilder";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
rule: RoutingRule | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function getFieldLabel(fieldName: string): string {
|
||||
const field = baseRoutingFields.find((f) => f.name === fieldName);
|
||||
return field?.label ?? fieldName;
|
||||
}
|
||||
|
||||
function formatRuleValue(value: any): string {
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
if (typeof value === "string") return value;
|
||||
return String(value ?? "");
|
||||
}
|
||||
|
||||
function useScopeName(scope: string, scopeId?: string): string | undefined {
|
||||
const { data: teamsData } = useGetTeamsQuery(undefined, { skip: scope !== "team" || !scopeId });
|
||||
const { data: customersData } = useGetCustomersQuery(undefined, { skip: scope !== "customer" || !scopeId });
|
||||
const { data: vksData } = useGetVirtualKeysQuery(undefined, { skip: scope !== "virtual_key" || !scopeId });
|
||||
|
||||
return useMemo(() => {
|
||||
if (!scopeId) return undefined;
|
||||
if (scope === "team") return teamsData?.teams?.find((t) => t.id === scopeId)?.name;
|
||||
if (scope === "customer") return customersData?.customers?.find((c) => c.id === scopeId)?.name;
|
||||
if (scope === "virtual_key") return vksData?.virtual_keys?.find((v) => v.id === scopeId)?.name;
|
||||
return undefined;
|
||||
}, [scope, scopeId, teamsData, customersData, vksData]);
|
||||
}
|
||||
|
||||
// ─── copy button ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CopyButton({ value, label, testId }: { value: string; label?: string; testId: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error("Failed to copy to clipboard");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? `${label ?? "value"} copied` : `Copy ${label ?? "value"}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{copied ? "Copied!" : `Copy ${label ?? "value"}`}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── condition rendering ─────────────────────────────────────────────────────
|
||||
|
||||
function ConditionRow({ rule }: { rule: RuleType }) {
|
||||
const fieldLabel = getFieldLabel(rule.field);
|
||||
const opLabel = getOperatorLabel(rule.operator);
|
||||
const value = formatRuleValue(rule.value);
|
||||
const isExistence = rule.operator === "null" || rule.operator === "notNull";
|
||||
|
||||
// Detect header/param fields for richer display
|
||||
const isHeader = rule.field.startsWith("headers[") || rule.field === "headers";
|
||||
const isParam = rule.field.startsWith("params[") || rule.field === "params";
|
||||
const keyMatch = rule.field.match(/\["([^"]+)"\]/);
|
||||
// Bare field (e.g. headers / params) may encode key:value in the value string
|
||||
const bareKeyValue =
|
||||
!keyMatch && (isHeader || isParam) && value
|
||||
? value.includes(":")
|
||||
? { key: value.slice(0, value.indexOf(":")), val: value.slice(value.indexOf(":") + 1) }
|
||||
: { key: value, val: "" }
|
||||
: null;
|
||||
const keyName = keyMatch?.[1] ?? bareKeyValue?.key;
|
||||
const displayValue = bareKeyValue !== null ? bareKeyValue.val : value;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 px-3 py-2 text-xs">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="shrink-0 font-medium">
|
||||
{isHeader && keyName ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground font-normal">header</span>
|
||||
<span className="font-mono">{keyName}</span>
|
||||
</span>
|
||||
) : isParam && keyName ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground font-normal">param</span>
|
||||
<span className="font-mono">{keyName}</span>
|
||||
</span>
|
||||
) : (
|
||||
fieldLabel
|
||||
)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground shrink-0">{opLabel}</span>
|
||||
{!isExistence && displayValue && (
|
||||
<code className="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono break-all">{displayValue}</code>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CombinatorPill({ combinator }: { combinator: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground text-[10px] font-semibold uppercase">{combinator}</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionGroup({ group, depth = 0 }: { group: RuleGroupType; depth?: number }) {
|
||||
const rules = group.rules ?? [];
|
||||
if (rules.length === 0) return null;
|
||||
|
||||
const content = rules.map((rule, i) => (
|
||||
<div key={i}>
|
||||
{i > 0 && <CombinatorPill combinator={group.combinator} />}
|
||||
{"combinator" in rule ? <ConditionGroup group={rule as RuleGroupType} depth={depth + 1} /> : <ConditionRow rule={rule as RuleType} />}
|
||||
</div>
|
||||
));
|
||||
|
||||
if (depth === 0) return <div className="rounded-md border py-1">{content}</div>;
|
||||
|
||||
return (
|
||||
<div className="border-foreground/25 relative mx-3 my-1 rounded border border-dashed py-1">
|
||||
<span className="bg-background text-muted-foreground absolute -top-2 right-2 rounded px-1 text-[10px] font-medium">Group</span>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── target card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function TargetCard({ target, total }: { target: RoutingRule["targets"][0]; index: number; total: number }) {
|
||||
const providerLabel = target.provider ? getProviderLabel(target.provider) : "Incoming provider";
|
||||
const weightPercent = total > 0 ? Math.round(target.weight * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{target.provider && <RenderProviderIcon provider={target.provider as ProviderIconType} size="sm" className="h-5 w-5 shrink-0" />}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{providerLabel}</span>
|
||||
{target.model ? (
|
||||
<span className="text-muted-foreground font-mono text-xs">{target.model}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Incoming model</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex cursor-default items-center gap-1.5">
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div className="bg-primary h-full rounded-full transition-all" style={{ width: `${weightPercent}%` }} />
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right font-mono text-xs">{weightPercent}%</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Weight: {target.weight} (raw)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{target.key_id && (
|
||||
<div className="bg-muted/50 flex items-center gap-1.5 rounded-md px-2 py-1">
|
||||
<Key className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
<span className="text-muted-foreground text-xs">Pinned key:</span>
|
||||
<code className="truncate font-mono text-xs">{target.key_id}</code>
|
||||
<CopyButton value={target.key_id} label="key ID" testId="routing-rule-copy-key-id-btn" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── fallback chain ───────────────────────────────────────────────────────────
|
||||
|
||||
function FallbackChain({ fallbacks }: { fallbacks: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-y-2">
|
||||
{fallbacks.map((fb, i) => {
|
||||
const parts = fb.split("/");
|
||||
const provider = parts[0] || "Incoming provider";
|
||||
const model = parts.length > 1 ? parts.slice(1).join("/") : "Incoming model";
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
{i > 0 && <span className="text-muted-foreground mx-1.5 text-xs">→</span>}
|
||||
<Badge variant="outline" className="gap-1.5 font-normal">
|
||||
{provider && <RenderProviderIcon provider={provider as ProviderIconType} size="sm" className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span className="font-mono text-xs">{model ? `${provider}/${model}` : fb}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main sheet ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function RoutingRuleInfoSheet({ rule, open, onOpenChange }: Props) {
|
||||
const targets = rule?.targets ?? [];
|
||||
const fallbacks = rule?.fallbacks ?? [];
|
||||
const hasQuery = rule?.query && (rule.query.rules?.length ?? 0) > 0;
|
||||
const scopeName = useScopeName(rule?.scope ?? "global", rule?.scope_id);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col overflow-x-hidden p-8 sm:max-w-2xl" data-testid="routing-rule-info">
|
||||
{rule && (
|
||||
<>
|
||||
<SheetHeader className="flex flex-col items-start gap-1 p-0">
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
<SheetTitle className="text-base">{rule.name}</SheetTitle>
|
||||
<Badge variant={rule.enabled ? "default" : "secondary"}>{rule.enabled ? "Enabled" : "Disabled"}</Badge>
|
||||
{rule.chain_rule && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="cursor-default gap-1">
|
||||
<GitMerge className="h-3 w-3" />
|
||||
Chain Rule
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64">
|
||||
After this rule matches, routing rules are re-evaluated using the resolved provider/model as the new context.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{rule.description && <SheetDescription className="mt-0.5 text-sm">{rule.description}</SheetDescription>}
|
||||
</SheetHeader>
|
||||
|
||||
<div className="-mx-8 space-y-6 overflow-y-auto px-8 pb-8">
|
||||
{/* Overview */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Overview</h3>
|
||||
<div className="grid gap-3">
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<span className="text-muted-foreground text-sm">Scope</span>
|
||||
<div className="col-span-2 flex items-center gap-1.5">
|
||||
<Badge variant="secondary">{getScopeLabel(rule.scope)}</Badge>
|
||||
{scopeName && <span className="text-sm">{scopeName}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 items-center gap-4">
|
||||
<span className="text-muted-foreground text-sm">Priority</span>
|
||||
<div className="col-span-2">
|
||||
<span className="bg-primary text-primary-foreground inline-block rounded px-2.5 py-0.5 text-xs font-medium">
|
||||
{rule.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DottedSeparator />
|
||||
|
||||
{/* Conditions */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Conditions</h3>
|
||||
{hasQuery ? <ConditionGroup group={rule.query!} /> : <p className="text-muted-foreground text-sm">Matches all requests</p>}
|
||||
|
||||
{/* CEL expression */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">CEL Expression</span>
|
||||
<CopyButton value={rule.cel_expression} label="expression" testId="routing-rule-copy-expression-btn" />
|
||||
</div>
|
||||
<code className="bg-muted/50 block w-full rounded-md border px-3 py-2 font-mono text-xs break-all">
|
||||
{rule.cel_expression || <span className="text-muted-foreground italic">true</span>}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DottedSeparator />
|
||||
|
||||
{/* Targets */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Targets ({targets.length})</h3>
|
||||
{targets.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{targets.map((target, i) => (
|
||||
<TargetCard key={i} target={target} index={i} total={targets.length} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No targets configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DottedSeparator />
|
||||
|
||||
{/* Fallback Chain */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Fallback Chain</h3>
|
||||
{fallbacks.length > 0 ? (
|
||||
<FallbackChain fallbacks={fallbacks} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No fallbacks configured</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DottedSeparator />
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase">Created</p>
|
||||
<span className="text-sm">{formatDistanceToNow(new Date(rule.created_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase">Last Updated</p>
|
||||
<span className="text-sm">{formatDistanceToNow(new Date(rule.updated_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
794
ui/app/workspace/routing-rules/views/routingRuleSheet.tsx
Normal file
794
ui/app/workspace/routing-rules/views/routingRuleSheet.tsx
Normal file
@@ -0,0 +1,794 @@
|
||||
/**
|
||||
* Routing Rule Dialog (Sheet)
|
||||
* Create/Edit form for routing rules
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ModelMultiselect } from "@/components/ui/modelMultiselect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import { getErrorMessage } from "@/lib/store";
|
||||
import { useGetCustomersQuery, useGetTeamsQuery, useGetVirtualKeysQuery } from "@/lib/store/apis/governanceApi";
|
||||
import { useGetAllKeysQuery, useGetProvidersQuery } from "@/lib/store/apis/providersApi";
|
||||
import { useCreateRoutingRuleMutation, useGetRoutingRulesQuery, useUpdateRoutingRuleMutation } from "@/lib/store/apis/routingRulesApi";
|
||||
import {
|
||||
DEFAULT_ROUTING_RULE_FORM_DATA,
|
||||
DEFAULT_ROUTING_TARGET,
|
||||
ROUTING_RULE_SCOPES,
|
||||
RoutingRule,
|
||||
RoutingRuleFormData,
|
||||
RoutingTargetFormData,
|
||||
} from "@/lib/types/routingRules";
|
||||
import { validateRateLimitAndBudgetRules, validateRoutingRules } from "@/lib/utils/celConverterRouting";
|
||||
import { Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { RuleGroupType } from "react-querybuilder";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface RoutingRuleDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingRule?: RoutingRule | null;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const defaultQuery: RuleGroupType = {
|
||||
combinator: "and",
|
||||
rules: [],
|
||||
};
|
||||
|
||||
// Lazy-load CEL builder (heavy dependency tree).
|
||||
const CELRuleBuilderLazy = lazy(() =>
|
||||
import("@/app/workspace/routing-rules/components/celBuilder/celRuleBuilder").then((mod) => ({
|
||||
default: mod.CELRuleBuilder,
|
||||
})),
|
||||
);
|
||||
const CELRuleBuilder = (props: React.ComponentProps<typeof CELRuleBuilderLazy>) => (
|
||||
<Suspense fallback={<div className="text-sm text-gray-500">Loading CEL builder...</div>}>
|
||||
<CELRuleBuilderLazy {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
export function RoutingRuleSheet({ open, onOpenChange, editingRule, onSuccess }: RoutingRuleDialogProps) {
|
||||
const { data: rulesData } = useGetRoutingRulesQuery();
|
||||
const rules = rulesData?.rules || [];
|
||||
const { data: providersData = [] } = useGetProvidersQuery();
|
||||
const { data: allKeysData = [] } = useGetAllKeysQuery();
|
||||
const { data: vksData = { virtual_keys: [] } } = useGetVirtualKeysQuery();
|
||||
const { data: teamsData = { teams: [], count: 0, total_count: 0, limit: 0, offset: 0 } } = useGetTeamsQuery();
|
||||
const { data: customersData = { customers: [] } } = useGetCustomersQuery();
|
||||
const [createRoutingRule, { isLoading: isCreating }] = useCreateRoutingRuleMutation();
|
||||
const [updateRoutingRule, { isLoading: isUpdating }] = useUpdateRoutingRuleMutation();
|
||||
|
||||
// State for targets and query (managed outside react-hook-form for complex nested structures)
|
||||
const [targets, setTargets] = useState<RoutingTargetFormData[]>([{ ...DEFAULT_ROUTING_TARGET }]);
|
||||
const [query, setQuery] = useState<RuleGroupType>(defaultQuery);
|
||||
const [builderKey, setBuilderKey] = useState(0);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<RoutingRuleFormData>({
|
||||
defaultValues: DEFAULT_ROUTING_RULE_FORM_DATA,
|
||||
});
|
||||
|
||||
const isEditing = !!editingRule;
|
||||
const isLoading = isCreating || isUpdating;
|
||||
const enabled = watch("enabled");
|
||||
const chainRule = watch("chain_rule");
|
||||
const scope = watch("scope");
|
||||
const scopeId = watch("scope_id");
|
||||
const fallbacks = watch("fallbacks");
|
||||
|
||||
// Get available providers from configured providers, plus any provider already
|
||||
// referenced by the current targets, existing rules' targets, or rules' fallbacks
|
||||
// so edited/removed providers are still visible in the dropdown.
|
||||
const availableProviders = Array.from(
|
||||
new Set([
|
||||
...providersData.map((p) => p.name),
|
||||
...(targets.map((t) => t.provider).filter(Boolean) as string[]),
|
||||
...(rules.flatMap((r) => r.targets?.map((t) => t.provider).filter(Boolean) ?? []) as string[]),
|
||||
...rules.flatMap((r) => (r.fallbacks ?? []).map((f) => f.split("/")[0]?.trim()).filter(Boolean)),
|
||||
]),
|
||||
);
|
||||
|
||||
// Initialize form data when editing rule changes
|
||||
useEffect(() => {
|
||||
if (editingRule) {
|
||||
setValue("id", editingRule.id);
|
||||
setValue("name", editingRule.name);
|
||||
setValue("description", editingRule.description);
|
||||
setValue("cel_expression", editingRule.cel_expression);
|
||||
setValue("fallbacks", editingRule.fallbacks || []);
|
||||
setValue("scope", editingRule.scope);
|
||||
setValue("scope_id", editingRule.scope_id || "");
|
||||
setValue("priority", editingRule.priority);
|
||||
setValue("enabled", editingRule.enabled);
|
||||
setValue("chain_rule", editingRule.chain_rule ?? false);
|
||||
if (editingRule.targets && editingRule.targets.length > 0) {
|
||||
setTargets(
|
||||
editingRule.targets.map((t) => ({
|
||||
...DEFAULT_ROUTING_TARGET,
|
||||
provider: t.provider || "",
|
||||
model: t.model || "",
|
||||
key_id: t.key_id || "",
|
||||
weight: t.weight,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
|
||||
}
|
||||
// Restore the query object if it exists, otherwise use default
|
||||
if (editingRule.query) {
|
||||
setQuery(editingRule.query);
|
||||
} else {
|
||||
setQuery(defaultQuery);
|
||||
}
|
||||
setBuilderKey((prev) => prev + 1);
|
||||
} else {
|
||||
reset();
|
||||
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
|
||||
setQuery(defaultQuery);
|
||||
setBuilderKey((prev) => prev + 1);
|
||||
}
|
||||
}, [editingRule, open, setValue, reset]);
|
||||
|
||||
const handleQueryChange = useCallback(
|
||||
(expression: string, newQuery: RuleGroupType) => {
|
||||
setValue("cel_expression", expression);
|
||||
setQuery(newQuery);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const addTarget = () => {
|
||||
const remaining = 1 - targets.reduce((sum, t) => sum + (t.weight || 0), 0);
|
||||
setTargets((prev) => [...prev, { ...DEFAULT_ROUTING_TARGET, weight: Math.max(0, parseFloat(remaining.toFixed(4))) }]);
|
||||
};
|
||||
|
||||
const removeTarget = (index: number) => {
|
||||
setTargets((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateTarget = (index: number, field: keyof RoutingTargetFormData, value: string | number) => {
|
||||
setTargets((prev) => prev.map((t, i) => (i === index ? { ...t, [field]: value } : t)));
|
||||
};
|
||||
|
||||
const totalWeight = targets.reduce((sum, t) => sum + (t.weight || 0), 0);
|
||||
|
||||
const onSubmit = (data: RoutingRuleFormData) => {
|
||||
// Validate scope_id is required when scope is not global
|
||||
if (data.scope !== "global" && !data.scope_id?.trim()) {
|
||||
toast.error(`${data.scope === "team" ? "Team" : data.scope === "customer" ? "Customer" : "Virtual Key"} is required`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate targets
|
||||
if (targets.length === 0) {
|
||||
toast.error("At least one routing target is required");
|
||||
return;
|
||||
}
|
||||
for (const t of targets) {
|
||||
if (t.weight <= 0) {
|
||||
toast.error("Each target weight must be greater than 0");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (Math.abs(totalWeight - 1) > 0.001) {
|
||||
toast.error(`Target weights must sum to 1, current total: ${totalWeight.toFixed(4)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate regex patterns in routing rules
|
||||
const regexErrors = validateRoutingRules(query);
|
||||
if (regexErrors.length > 0) {
|
||||
toast.error(`Invalid regex pattern:\n${regexErrors.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate rate limit and budget rules
|
||||
const rateLimitErrors = validateRateLimitAndBudgetRules(query);
|
||||
if (rateLimitErrors.length > 0) {
|
||||
toast.error(`Invalid rule configuration:\n${rateLimitErrors.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out incomplete fallbacks (empty provider)
|
||||
const validFallbacks = (data.fallbacks || []).filter((fb) => {
|
||||
const provider = fb.split("/")[0]?.trim();
|
||||
return provider && provider.length > 0;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
cel_expression: data.cel_expression,
|
||||
targets: targets.map(({ provider, model, key_id, weight }) => ({
|
||||
provider: provider || undefined,
|
||||
model: model || undefined,
|
||||
key_id: key_id || undefined,
|
||||
weight,
|
||||
})),
|
||||
fallbacks: validFallbacks,
|
||||
scope: data.scope,
|
||||
scope_id: data.scope === "global" ? undefined : data.scope_id || undefined,
|
||||
priority: data.priority,
|
||||
enabled: data.enabled,
|
||||
chain_rule: data.chain_rule,
|
||||
query: query,
|
||||
};
|
||||
|
||||
const submitPromise =
|
||||
isEditing && editingRule
|
||||
? updateRoutingRule({
|
||||
id: editingRule.id,
|
||||
data: payload,
|
||||
}).unwrap()
|
||||
: createRoutingRule(payload).unwrap();
|
||||
|
||||
submitPromise
|
||||
.then(() => {
|
||||
toast.success(isEditing ? "Routing rule updated successfully" : "Routing rule created successfully");
|
||||
reset();
|
||||
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
|
||||
setQuery(defaultQuery);
|
||||
setBuilderKey((prev) => prev + 1);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
setTargets([{ ...DEFAULT_ROUTING_TARGET }]);
|
||||
setQuery(defaultQuery);
|
||||
setBuilderKey((prev) => prev + 1);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full min-w-1/2 flex-col gap-4 overflow-x-hidden p-8">
|
||||
<SheetHeader className="flex flex-col items-start">
|
||||
<SheetTitle>{isEditing ? "Edit Routing Rule" : "Create New Routing Rule"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isEditing ? "Update the routing rule configuration" : "Create a new CEL-based routing rule for intelligent request routing"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Rule Name */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="name">
|
||||
Rule Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Route GPT-4 to Azure"
|
||||
{...register("name", { required: "Rule name is required", maxLength: 255 })}
|
||||
/>
|
||||
{errors.name && <p className="text-destructive text-sm">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" placeholder="Describe what this rule does..." rows={2} {...register("description")} />
|
||||
</div>
|
||||
|
||||
{/* Enabled Switch */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="enabled">Enable Rule</Label>
|
||||
<p className="text-muted-foreground text-sm">Rule will be active and applied to matching requests</p>
|
||||
</div>
|
||||
<Switch id="enabled" checked={enabled} onCheckedChange={(checked) => setValue("enabled", checked)} />
|
||||
</div>
|
||||
|
||||
{/* Chain Rule Switch */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="chain_rule">Chain Rule</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
After this rule matches, re-evaluate routing rules using the resolved provider/model as the new context. Useful for
|
||||
composing rules — e.g. normalize a model alias first, then route based on the canonical name.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="chain_rule"
|
||||
checked={chainRule}
|
||||
onCheckedChange={(checked) => setValue("chain_rule", checked)}
|
||||
data-testid="routing-rule-chain-rule-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope and Priority - Side by Side */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="scope">Scope</Label>
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
setValue("scope", value as any);
|
||||
// Clear scope_id when scope changes
|
||||
setValue("scope_id", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select scope..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUTING_RULE_SCOPES.map((scopeOption) => (
|
||||
<SelectItem key={scopeOption.value} value={scopeOption.value}>
|
||||
{scopeOption.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="priority">
|
||||
Priority <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
{...register("priority", {
|
||||
required: "Priority is required",
|
||||
min: { value: 0, message: "Priority must be ≥ 0" },
|
||||
max: { value: 1000, message: "Priority must be ≤ 1000" },
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">Lower numbers = higher priority (0 is highest)</p>
|
||||
{errors.priority && <p className="text-destructive text-sm">{errors.priority.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope !== "global" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scope_id">
|
||||
{scope === "team" ? "Team" : scope === "customer" ? "Customer" : "Virtual Key"} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{scope === "team" && teamsData.teams.length > 0 && (
|
||||
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a team..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teamsData.teams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{scope === "customer" && customersData.customers.length > 0 && (
|
||||
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a customer..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customersData.customers.map((customer) => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{scope === "virtual_key" && vksData.virtual_keys.length > 0 && (
|
||||
<Select value={scopeId || ""} onValueChange={(value) => setValue("scope_id", value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a virtual key..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vksData.virtual_keys.map((vk) => (
|
||||
<SelectItem key={vk.id} value={vk.id}>
|
||||
{vk.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{((scope === "team" && teamsData.teams.length === 0) ||
|
||||
(scope === "customer" && customersData.customers.length === 0) ||
|
||||
(scope === "virtual_key" && vksData.virtual_keys.length === 0)) && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No {scope === "team" ? "teams" : scope === "customer" ? "customers" : "virtual keys"} available
|
||||
</p>
|
||||
)}
|
||||
{errors.scope_id && <p className="text-destructive text-sm">{errors.scope_id.message}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* CEL Rule Builder */}
|
||||
<div className="space-y-3">
|
||||
<Label>Rule Builder</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Build conditions to determine when this rule should apply. Leave empty to apply this rule to all requests.
|
||||
</p>
|
||||
<CELRuleBuilder
|
||||
key={builderKey}
|
||||
initialQuery={query}
|
||||
onChange={handleQueryChange}
|
||||
providers={availableProviders}
|
||||
models={[]}
|
||||
allowCustomModels={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Note about Token/Request Limits and Budget Configuration */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Note: Ensure token limits, request limits, and budget are configured in{" "}
|
||||
<strong>Model Providers → Configurations → {"{provider}"} → Governance</strong> (provider-level) or{" "}
|
||||
<strong>Model Providers → Budgets & Limits</strong> section (model-level) before using them in routing rules.
|
||||
</p>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Routing Targets */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Routing Targets</Label>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
Weights must sum to 1. Leave provider or model empty to use the incoming request value.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTarget}
|
||||
className="shrink-0 gap-2"
|
||||
data-testid="routing-rule-target-add"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Target
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{targets.map((target, index) => (
|
||||
<TargetRow
|
||||
key={index}
|
||||
target={target}
|
||||
index={index}
|
||||
availableProviders={availableProviders}
|
||||
allKeys={allKeysData}
|
||||
showRemove={targets.length > 1}
|
||||
onUpdate={updateTarget}
|
||||
onRemove={removeTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Weight sum indicator */}
|
||||
<div
|
||||
className={`flex items-center justify-end gap-2 text-xs font-medium ${Math.abs(totalWeight - 1) > 0.001 ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
Total weight: {totalWeight.toFixed(4)}
|
||||
{Math.abs(totalWeight - 1) > 0.001 && <span className="text-destructive">(must equal 1)</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallbacks */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Fallbacks</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setValue("fallbacks", [...(fallbacks || []), ""])}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Fallback
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(fallbacks || []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No fallbacks configured</p>
|
||||
) : (
|
||||
(fallbacks || []).map((fallback, index) => {
|
||||
// Parse provider/model from fallback string
|
||||
const parts = fallback.split("/");
|
||||
const fbProvider = parts[0] || "";
|
||||
const fbModel = parts[1] || "";
|
||||
|
||||
const handleProviderChange = (newProvider: string) => {
|
||||
const model = fbModel || "";
|
||||
const newFallback = `${newProvider}/${model}`;
|
||||
const newFallbacks = [...fallbacks];
|
||||
newFallbacks[index] = newFallback;
|
||||
setValue("fallbacks", newFallbacks);
|
||||
};
|
||||
|
||||
const handleModelChange = (newModel: string) => {
|
||||
const prov = fbProvider || "";
|
||||
const newFallback = `${prov}/${newModel}`;
|
||||
const newFallbacks = [...fallbacks];
|
||||
newFallbacks[index] = newFallback;
|
||||
setValue("fallbacks", newFallbacks);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
const newFallbacks = fallbacks.filter((_: string, i: number) => i !== index);
|
||||
setValue("fallbacks", newFallbacks);
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Select value={fbProvider} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((prov) => (
|
||||
<SelectItem key={prov} value={prov}>
|
||||
<div className="flex items-center gap-2">
|
||||
<RenderProviderIcon provider={prov as ProviderIconType} size="sm" className="h-4 w-4" />
|
||||
<span>{getProviderLabel(prov)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ModelMultiselect
|
||||
provider={fbProvider || undefined}
|
||||
value={fbModel}
|
||||
onChange={handleModelChange}
|
||||
placeholder="Select model..."
|
||||
isSingleSelect
|
||||
disabled={!fbProvider}
|
||||
className="!h-9 !min-h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
className="h-9 px-2"
|
||||
aria-label={`Remove fallback ${index + 1}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">Fallbacks will be used in the order they are defined</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isLoading}>
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isEditing ? "Update Rule" : "Save Rule"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
interface TargetRowProps {
|
||||
target: RoutingTargetFormData;
|
||||
index: number;
|
||||
availableProviders: string[];
|
||||
allKeys: Array<{ key_id: string; name: string; provider: string }>;
|
||||
showRemove: boolean;
|
||||
onUpdate: (index: number, field: keyof RoutingTargetFormData, value: string | number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function TargetRow({ target, index, availableProviders, allKeys, showRemove, onUpdate, onRemove }: TargetRowProps) {
|
||||
const availableKeys = target.provider
|
||||
? allKeys.filter((k) => k.provider === target.provider).map((k) => ({ id: k.key_id, name: k.name }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border p-3" data-testid={`routing-target-${index}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm font-medium">Target {index + 1}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor={`routing-target-${index}-weight-input`} className="text-muted-foreground shrink-0 text-xs">
|
||||
Weight
|
||||
</Label>
|
||||
<Input
|
||||
id={`routing-target-${index}-weight-input`}
|
||||
type="number"
|
||||
min={0.001}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={target.weight}
|
||||
onChange={(e) => onUpdate(index, "weight", parseFloat(e.target.value) || 0)}
|
||||
className="h-8 w-24 text-sm"
|
||||
data-testid={`routing-target-${index}-weight-input`}
|
||||
/>
|
||||
</div>
|
||||
{showRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label={`Remove target ${index + 1}`}
|
||||
data-testid={`routing-target-${index}-remove-button`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label id={`routing-target-${index}-provider-label`} className="text-xs">
|
||||
Provider
|
||||
</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select
|
||||
value={target.provider}
|
||||
onValueChange={(value) => {
|
||||
onUpdate(index, "provider", value);
|
||||
onUpdate(index, "model", "");
|
||||
onUpdate(index, "key_id", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`routing-target-${index}-provider-select`}
|
||||
aria-labelledby={`routing-target-${index}-provider-label`}
|
||||
className="h-9 flex-1 text-sm"
|
||||
data-testid={`routing-target-${index}-provider-select`}
|
||||
>
|
||||
<SelectValue placeholder="Incoming (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((prov) => (
|
||||
<SelectItem key={prov} value={prov}>
|
||||
<div className="flex items-center gap-2">
|
||||
<RenderProviderIcon provider={prov as ProviderIconType} size="sm" className="h-4 w-4" />
|
||||
<span>{getProviderLabel(prov)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{target.provider && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onUpdate(index, "provider", "");
|
||||
onUpdate(index, "model", "");
|
||||
onUpdate(index, "key_id", "");
|
||||
}}
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label={`Clear provider for target ${index + 1}`}
|
||||
data-testid={`routing-target-${index}-provider-clear`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label id={`routing-target-${index}-model-label`} className="text-xs">
|
||||
Model
|
||||
</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex-1" data-testid={`routing-target-${index}-model-select`}>
|
||||
<ModelMultiselect
|
||||
provider={target.provider || undefined}
|
||||
value={target.model}
|
||||
onChange={(value) => onUpdate(index, "model", value)}
|
||||
placeholder="Incoming (optional)"
|
||||
isSingleSelect
|
||||
loadModelsOnEmptyProvider
|
||||
className="!h-9 !min-h-9"
|
||||
inputId={`routing-target-${index}-model-input`}
|
||||
ariaLabelledBy={`routing-target-${index}-model-label`}
|
||||
/>
|
||||
</div>
|
||||
{target.model && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onUpdate(index, "model", "")}
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label={`Clear model for target ${index + 1}`}
|
||||
data-testid={`routing-target-${index}-model-clear`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{target.provider && (availableKeys.length > 0 || target.key_id) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label id={`routing-target-${index}-apikey-label`} className="text-xs">
|
||||
API Key <span className="text-muted-foreground">(optional — leave unset for load-balanced selection)</span>
|
||||
</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<Select value={target.key_id || ""} onValueChange={(value) => onUpdate(index, "key_id", value)}>
|
||||
<SelectTrigger
|
||||
id={`routing-target-${index}-apikey-select`}
|
||||
aria-labelledby={`routing-target-${index}-apikey-label`}
|
||||
className="h-9 flex-1 text-sm"
|
||||
data-testid={`routing-target-${index}-apikey-select`}
|
||||
>
|
||||
<SelectValue placeholder="Select key (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableKeys.map((key) => (
|
||||
<SelectItem key={key.id} value={key.id}>
|
||||
{key.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{target.key_id && !availableKeys.some((k) => k.id === target.key_id) && (
|
||||
<SelectItem key={`pinned-${target.key_id}`} value={target.key_id}>
|
||||
(pinned) {target.key_id}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{target.key_id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onUpdate(index, "key_id", "")}
|
||||
className="h-9 w-9 p-0"
|
||||
aria-label={`Clear API key for target ${index + 1}`}
|
||||
data-testid={`routing-target-${index}-apikey-clear`}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Route } from "lucide-react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
const ROUTING_RULES_DOCS_URL = "https://docs.getbifrost.ai/providers/routing-rules";
|
||||
|
||||
interface RoutingRulesEmptyStateProps {
|
||||
onAddClick: () => void;
|
||||
canCreate?: boolean;
|
||||
}
|
||||
|
||||
export function RoutingRulesEmptyState({ onAddClick, canCreate = true }: RoutingRulesEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[80vh] w-full flex-col items-center justify-center gap-4 py-16 text-center"
|
||||
data-testid="routing-rules-empty-state"
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
<Route className="h-[5.5rem] w-[5.5rem]" strokeWidth={1} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-muted-foreground text-xl font-medium">Routing rules direct requests using CEL conditions</h1>
|
||||
<div className="text-muted-foreground mx-auto mt-2 max-w-[600px] text-sm font-normal">
|
||||
Create CEL-based rules to route requests by model, provider, budget, or custom attributes. Control which provider or model handles
|
||||
each request.
|
||||
</div>
|
||||
<div className="mx-auto mt-6 flex flex-row flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-label="Read more about routing rules (opens in new tab)"
|
||||
data-testid="routing-rules-empty-read-more"
|
||||
onClick={() => {
|
||||
window.open(`${ROUTING_RULES_DOCS_URL}?utm_source=bfd`, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
Read more <ArrowUpRight className="text-muted-foreground h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Create your first routing rule"
|
||||
data-testid="create-routing-rule-btn"
|
||||
onClick={onAddClick}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
New Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
ui/app/workspace/routing-rules/views/routingRulesTable.tsx
Normal file
275
ui/app/workspace/routing-rules/views/routingRulesTable.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Routing Rules Table
|
||||
* Displays all routing rules with CRUD actions
|
||||
*/
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alertDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons";
|
||||
import { getProviderLabel } from "@/lib/constants/logs";
|
||||
import { getErrorMessage } from "@/lib/store";
|
||||
import { useDeleteRoutingRuleMutation } from "@/lib/store/apis/routingRulesApi";
|
||||
import { RoutingRule, RoutingTarget } from "@/lib/types/routingRules";
|
||||
import { getPriorityBadgeClass, getScopeLabel, truncateCELExpression } from "@/lib/utils/routingRules";
|
||||
import { ChevronLeft, ChevronRight, Edit, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface RoutingRulesTableProps {
|
||||
rules: RoutingRule[] | undefined;
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
onEdit: (rule: RoutingRule) => void;
|
||||
onRowClick: (rule: RoutingRule) => void;
|
||||
/** When false, delete button is hidden and deletion is disabled (e.g. for read-only users). */
|
||||
canDelete?: boolean;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onOffsetChange: (offset: number) => void;
|
||||
}
|
||||
|
||||
export function RoutingRulesTable({
|
||||
rules,
|
||||
totalCount,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onRowClick,
|
||||
canDelete = false,
|
||||
search,
|
||||
onSearchChange,
|
||||
offset,
|
||||
limit,
|
||||
onOffsetChange,
|
||||
}: RoutingRulesTableProps) {
|
||||
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null);
|
||||
const [deleteRoutingRule, { isLoading: isDeleting }] = useDeleteRoutingRuleMutation();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!canDelete || !deleteRuleId) return;
|
||||
|
||||
try {
|
||||
await deleteRoutingRule(deleteRuleId).unwrap();
|
||||
toast.success("Routing rule deleted successfully");
|
||||
setDeleteRuleId(null);
|
||||
} catch (error: any) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Targets</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead className="text-right">Priority</TableHead>
|
||||
<TableHead>Expression</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell colSpan={7} className="h-10">
|
||||
<div className="bg-muted h-2 w-32 animate-pulse rounded" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sortedRules = rules ? [...rules].sort((a, b) => a.priority - b.priority) : [];
|
||||
const ruleToDelete = sortedRules.find((r) => r.id === deleteRuleId);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toolbar: Search */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
aria-label="Search routing rules by name"
|
||||
placeholder="Search by name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
data-testid="routing-rules-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-sm border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="font-semibold">Name</TableHead>
|
||||
<TableHead className="font-semibold">Targets</TableHead>
|
||||
<TableHead className="font-semibold">Scope</TableHead>
|
||||
<TableHead className="text-right font-semibold">Priority</TableHead>
|
||||
<TableHead className="font-semibold">Expression</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="text-right font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedRules.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center">
|
||||
<span className="text-muted-foreground text-sm">No matching routing rules found.</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedRules.map((rule) => (
|
||||
<TableRow key={rule.id} className="hover:bg-muted/50 cursor-pointer transition-colors" onClick={() => onRowClick(rule)}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-xs truncate">{rule.name}</span>
|
||||
{rule.description && (
|
||||
<span data-testid="routing-rule-description" className="text-muted-foreground max-w-xs truncate text-xs">
|
||||
{rule.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TargetsSummary targets={rule.targets || []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{getScopeLabel(rule.scope)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className={`inline-block rounded px-2.5 py-1 text-xs font-medium ${getPriorityBadgeClass()}`}>{rule.priority}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-muted-foreground block max-w-xs truncate font-mono text-xs" title={rule.cel_expression}>
|
||||
{truncateCELExpression(rule.cel_expression)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={rule.enabled ? "default" : "secondary"}>{rule.enabled ? "Enabled" : "Disabled"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(rule)}
|
||||
aria-label="Edit routing rule"
|
||||
data-testid={`routing-rule-edit-${rule.id}-btn`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteRuleId(rule.id)}
|
||||
aria-label="Delete routing rule"
|
||||
data-testid={`routing-rule-delete-${rule.id}-btn`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
|
||||
data-testid="routing-rules-pagination-prev-btn"
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={offset + limit >= totalCount}
|
||||
onClick={() => onOffsetChange(offset + limit)}
|
||||
data-testid="routing-rules-pagination-next-btn"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={!!deleteRuleId} onOpenChange={(open) => !open && setDeleteRuleId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Routing Rule</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{ruleToDelete?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={isDeleting} className="bg-destructive hover:bg-destructive/90">
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetsSummary({ targets }: { targets: RoutingTarget[] }) {
|
||||
if (!targets || targets.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">-</span>;
|
||||
}
|
||||
|
||||
const first = targets[0];
|
||||
const label = [first.provider ? getProviderLabel(first.provider) : "Any", first.model || "Any model"].join(" / ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{first.provider && <RenderProviderIcon provider={first.provider as ProviderIconType} size="sm" className="h-4 w-4 shrink-0" />}
|
||||
<span className="max-w-[160px] truncate text-sm">{label}</span>
|
||||
</div>
|
||||
{targets.length > 1 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
+{targets.length - 1} more target{targets.length > 2 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
ui/app/workspace/routing-rules/views/routingRulesView.tsx
Normal file
139
ui/app/workspace/routing-rules/views/routingRulesView.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Routing Rules View
|
||||
* Main orchestrator component for routing rules management
|
||||
*/
|
||||
|
||||
import { RbacOperation, RbacResource, useRbac } from "@/app/_fallbacks/enterprise/lib/contexts/rbacContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDebouncedValue } from "@/hooks/useDebounce";
|
||||
import { useGetRoutingRulesQuery } from "@/lib/store/apis/routingRulesApi";
|
||||
import { RoutingRule } from "@/lib/types/routingRules";
|
||||
import { GitBranch, Plus } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RoutingRuleInfoSheet } from "./routingRuleInfoSheet";
|
||||
import { RoutingRuleSheet } from "./routingRuleSheet";
|
||||
import { RoutingRulesEmptyState } from "./routingRulesEmptyState";
|
||||
import { RoutingRulesTable } from "./routingRulesTable";
|
||||
|
||||
const POLLING_INTERVAL = 5000;
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function RoutingRulesView() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
|
||||
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
|
||||
const [selectedRule, setSelectedRule] = useState<RoutingRule | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const debouncedSearch = useDebouncedValue(search, 300);
|
||||
|
||||
// Reset to first page when search changes
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Permissions
|
||||
const canCreate = useRbac(RbacResource.RoutingRules, RbacOperation.Create);
|
||||
const canDelete = useRbac(RbacResource.RoutingRules, RbacOperation.Delete);
|
||||
|
||||
// API
|
||||
const { data: rulesData, isLoading } = useGetRoutingRulesQuery(
|
||||
{
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
search: debouncedSearch || undefined,
|
||||
},
|
||||
{
|
||||
pollingInterval: POLLING_INTERVAL,
|
||||
},
|
||||
);
|
||||
|
||||
const rules = rulesData?.rules || [];
|
||||
const totalCount = rulesData?.total_count || 0;
|
||||
|
||||
// Snap offset back when total shrinks past current page (e.g. delete last item on last page)
|
||||
useEffect(() => {
|
||||
if (!rulesData || offset < totalCount) return;
|
||||
setOffset(totalCount === 0 ? 0 : Math.floor((totalCount - 1) / PAGE_SIZE) * PAGE_SIZE);
|
||||
}, [totalCount, offset]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setEditingRule(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (rule: RoutingRule) => {
|
||||
setEditingRule(rule);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRowClick = (rule: RoutingRule) => {
|
||||
setSelectedRule(rule);
|
||||
setInfoSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setEditingRule(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveFilters = debouncedSearch;
|
||||
|
||||
// True empty state: no rules at all (not just filtered to zero)
|
||||
if (!isLoading && totalCount === 0 && !hasActiveFilters) {
|
||||
return (
|
||||
<>
|
||||
<RoutingRulesEmptyState onAddClick={handleCreateNew} canCreate={canCreate} />
|
||||
<RoutingRuleSheet open={dialogOpen} onOpenChange={handleDialogOpenChange} editingRule={editingRule} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-foreground text-lg font-semibold">Routing Rules</h1>
|
||||
<p className="text-muted-foreground text-sm">Manage CEL-based routing rules for intelligent request routing across providers</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link to="/workspace/routing-rules/tree">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">View Tree</span>
|
||||
</Link>
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button data-testid="create-routing-rule-btn" onClick={handleCreateNew} disabled={isLoading} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">New Rule</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoutingRulesTable
|
||||
rules={rules}
|
||||
totalCount={totalCount}
|
||||
isLoading={isLoading}
|
||||
onEdit={handleEdit}
|
||||
onRowClick={handleRowClick}
|
||||
canDelete={canDelete}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
offset={offset}
|
||||
limit={PAGE_SIZE}
|
||||
onOffsetChange={setOffset}
|
||||
/>
|
||||
|
||||
<RoutingRuleSheet open={dialogOpen} onOpenChange={handleDialogOpenChange} editingRule={editingRule} />
|
||||
<RoutingRuleInfoSheet rule={selectedRule} open={infoSheetOpen} onOpenChange={setInfoSheetOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user