diff --git a/framework/configstore/tables/routing_rules.go b/framework/configstore/tables/routing_rules.go index 85c56b9abf..2ab826be73 100644 --- a/framework/configstore/tables/routing_rules.go +++ b/framework/configstore/tables/routing_rules.go @@ -19,7 +19,7 @@ type TableRoutingRule struct { CelExpression string `gorm:"type:text;not null" json:"cel_expression"` // Routing Targets (output) — 1:many relationship; weights must sum to 1 - Targets []TableRoutingTarget `gorm:"foreignKey:RuleID;constraint:OnDelete:CASCADE" json:"targets,omitempty"` + Targets []TableRoutingTarget `gorm:"foreignKey:RuleID;constraint:OnDelete:CASCADE" json:"targets"` Fallbacks *string `gorm:"type:text" json:"-"` // JSON array of fallback chains ParsedFallbacks []string `gorm:"-" json:"fallbacks,omitempty"` // Parsed fallbacks from JSON diff --git a/ui/app/globals.css b/ui/app/globals.css index 418f86667f..131374f5d2 100644 --- a/ui/app/globals.css +++ b/ui/app/globals.css @@ -280,6 +280,35 @@ div.content-container:has(.no-border-parent) { fill: var(--foreground) !important; } +/* Dynamic chain: dash period 3+5 = 8 — offset must move exactly one period per loop */ +@keyframes rf-routing-tree-dynamic-chain-dash { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: -8; + } +} + +.rf-chain-legend-dynamic-dash { + animation: rf-routing-tree-dynamic-chain-dash 0.5s linear infinite; +} + +.react-flow__edge.rf-chain-edge-dynamic .react-flow__edge-path { + stroke-dasharray: 3 5; + animation: rf-routing-tree-dynamic-chain-dash 0.5s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .rf-chain-legend-dynamic-dash { + animation: none; + } + + .react-flow__edge.rf-chain-edge-dynamic .react-flow__edge-path { + animation: none; + } +} + /* // Custom styling for streamdown */ [data-streamdown="code-block"], [data-streamdown="code-block-body"]{ diff --git a/ui/app/workspace/routing-rules/tree/views/celParser.ts b/ui/app/workspace/routing-rules/tree/views/celParser.ts new file mode 100644 index 0000000000..c28a2d242a --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/celParser.ts @@ -0,0 +1,151 @@ +/** + * 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): 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, " "); +} diff --git a/ui/app/workspace/routing-rules/tree/views/constants.ts b/ui/app/workspace/routing-rules/tree/views/constants.ts new file mode 100644 index 0000000000..23d28d7c63 --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/constants.ts @@ -0,0 +1,32 @@ +// ─── 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; diff --git a/ui/app/workspace/routing-rules/tree/views/graphBuilder.ts b/ui/app/workspace/routing-rules/tree/views/graphBuilder.ts new file mode 100644 index 0000000000..b1892d455c --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/graphBuilder.ts @@ -0,0 +1,453 @@ +/** + * 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; + 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(); + 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(); + const nodeCanon = new Map(); + + function canon(node: TrieNode, seen = new Set()): 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()): 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()): 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()): 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 | 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(); + 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(); + const lNodes: LNode[] = [{ id: "source", kind: "source", data: {}, w: SRC_W, h: SRC_H }]; + const lEdges: LEdge[] = []; + const addedNodes = new Set(["source"]); + const addedEdges = new Set(); + const processed = new Set(); + const chainQueue: { ruleId: string; rule: RoutingRule; sc: string }[] = []; + + function addEdge(src: string, tgt: string, label?: string, color?: string, opts?: Partial) { + 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(); + 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): Array<{ id: string; strong: boolean }> { + const results: Array<{ id: string; strong: boolean }> = []; + const visited = new Set(); + + /** 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(); + for (const t of rule.targets) { + const vars: Record = {}; + 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 { + 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(); + 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(); + 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, + 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 = { + 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 }; +} diff --git a/ui/app/workspace/routing-rules/tree/views/node/rfConditionNode.tsx b/ui/app/workspace/routing-rules/tree/views/node/rfConditionNode.tsx new file mode 100644 index 0000000000..e76fd0371a --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/node/rfConditionNode.tsx @@ -0,0 +1,62 @@ +"use client"; + +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 ( +
+ + +
+
+
+ + {condition} + + {scopes.length > 0 && ( +
+ {scopes.map((sc) => { + const cfg = SCOPE_CONFIG[sc as ScopeKey]; + return cfg ? ( + + {cfg.label} + + ) : null; + })} +
+ )} +
+
+
+ ); +} diff --git a/ui/app/workspace/routing-rules/tree/views/node/rfEdgeHandle.tsx b/ui/app/workspace/routing-rules/tree/views/node/rfEdgeHandle.tsx new file mode 100644 index 0000000000..3bd2edb2ba --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/node/rfEdgeHandle.tsx @@ -0,0 +1,30 @@ +"use client"; + +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 & { + className?: string; + accentColor?: string; +}; + +export function RFEdgeHandle({ className, accentColor, style, ...rest }: RFEdgeHandleProps) { + return ( + + ); +} diff --git a/ui/app/workspace/routing-rules/tree/views/node/rfRuleNode.tsx b/ui/app/workspace/routing-rules/tree/views/node/rfRuleNode.tsx new file mode 100644 index 0000000000..4819d20889 --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/node/rfRuleNode.tsx @@ -0,0 +1,145 @@ +"use client"; + +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 ( +
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); } }} + > + + {rule.chain_rule && ( + + )} +
+ + {/* scope header */} +
+ + + {cfg?.label ?? rule.scope} + +
+ {rule.chain_rule && ( + + )} + {!rule.enabled && ( + Off + )} +
+
+ + {/* rule name */} +
+

{rule.name}

+ {rule.priority > 0 && ( +

Priority {rule.priority}

+ )} +
+ + {/* targets footer */} +
+
+ {rule.targets.slice(0, 4).map((t, i) => + t.provider + ? + : + )} + {rule.targets.length > 4 && ( + +{rule.targets.length - 4} + )} +
+ + {rule.targets.length} target{rule.targets.length !== 1 ? "s" : ""} + +
+ + {/* hover popover */} + {hovered && ( +
+ {rule.scope !== "global" && rule.scope_id && ( +
+

+ {cfg?.label ?? rule.scope}: + {rule.scope_id} +

+
+ )} + {rule.chain_rule && ( +
+ +

+ Chain rule — resolved provider/model feeds back as the new input and the full scope chain re-evaluates. +

+
+ )} +

+ {rule.chain_rule ? "Resolved target (new input)" : "Targets"} +

+ {rule.targets.map((t, i) => { + const isPassthrough = !t.provider && !t.model; + return ( +
+ {t.provider + ? + : + } +
+

+ {isPassthrough ? "Passthrough" : (t.provider ? getProviderLabel(t.provider) : t.model)} +

+ {t.model && t.provider && ( +

{t.model}

+ )} + {isPassthrough && ( +

original provider & model

+ )} +
+ {multi && ( + + {Math.round(t.weight * 100)}% + + )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/ui/app/workspace/routing-rules/tree/views/node/rfSourceNode.tsx b/ui/app/workspace/routing-rules/tree/views/node/rfSourceNode.tsx new file mode 100644 index 0000000000..389833f4b3 --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/node/rfSourceNode.tsx @@ -0,0 +1,25 @@ +"use client"; + +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 ( +
+ +
+
+ + Incoming Request +
+

provider · model · headers · params · limits

+
+
+ ); +} diff --git a/ui/app/workspace/routing-rules/tree/views/positionPersistence.ts b/ui/app/workspace/routing-rules/tree/views/positionPersistence.ts new file mode 100644 index 0000000000..d9740fe856 --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/positionPersistence.ts @@ -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; + 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("|"); +} diff --git a/ui/app/workspace/routing-rules/tree/views/rfChainEdge.tsx b/ui/app/workspace/routing-rules/tree/views/rfChainEdge.tsx new file mode 100644 index 0000000000..7ccacdf619 --- /dev/null +++ b/ui/app/workspace/routing-rules/tree/views/rfChainEdge.tsx @@ -0,0 +1,102 @@ +"use client"; + +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 ( + <> + + + {weak ? ( + + ) : ( + + )} + + + ); +} + +export const RfChainEdge = memo(RfChainEdgeImpl); diff --git a/ui/app/workspace/routing-rules/tree/views/routingTreeView.tsx b/ui/app/workspace/routing-rules/tree/views/routingTreeView.tsx index 1941a56d24..0fa826c1cc 100644 --- a/ui/app/workspace/routing-rules/tree/views/routingTreeView.tsx +++ b/ui/app/workspace/routing-rules/tree/views/routingTreeView.tsx @@ -10,820 +10,32 @@ "use client"; +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 { RoutingRule } from "@/lib/types/routingRules"; +import type { Node, NodeChange } from "@xyflow/react"; import { - ReactFlow, Background, + BackgroundVariant, Controls, Panel, - Handle, - Position, - BackgroundVariant, - useNodesState, + ReactFlow, useEdgesState, + useNodesState, } from "@xyflow/react"; -import type { Node, Edge, NodeChange } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { ArrowLeft, GitBranch, Link2, Network, AlertCircle, Loader2, Search, X } from "lucide-react"; +import { AlertCircle, ArrowLeft, GitBranch, Info, Link2, Loader2, RotateCcw, Search } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCookies } from "react-cookie"; -import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; -import { getProviderLabel } from "@/lib/constants/logs"; - -// ─── Scope config ────────────────────────────────────────────────────────── - -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; - -type ScopeKey = keyof typeof SCOPE_CONFIG; - -// ─── Chain condition evaluator ───────────────────────────────────────────── - -/** - * 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. - */ -function evalChainCondition(cond: string, vars: Record): 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 -} -const SCOPE_ORDER = ["virtual_key", "team", "customer", "global"] as const; - -// ─── 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); -} - -// ─── Position persistence ────────────────────────────────────────────────── - -const POSITIONS_COOKIE = "bf-routing-tree-positions"; - -interface PositionCookie { - fingerprint: string; - positions: Record; - viewport?: { x: number; y: number; zoom: number }; -} - -/** Changes whenever any rule is added, edited, or deleted. */ -function computeFingerprint(rules: RoutingRule[]): string { - return rules - .map((r) => `${r.id}:${r.updated_at}`) - .sort() - .join("|"); -} - -// ─── Layout constants (LR: W = horizontal, H = vertical) ────────────────── - -const SRC_W = 260; const SRC_H = 80; -const COND_W = 310; const COND_H = 64; -const RULE_W = 220; const RULE_H = 106; -const H_GAP = 360; // gap between columns -const V_GAP = 60; // gap between rows within a column - -// ─── CEL parser ──────────────────────────────────────────────────────────── - -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 s = op === "||" && isWrappedInParens(expr.trim()) - ? expr.trim().slice(1, -1) - : expr.trim(); - 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; -} - -/** Expand a CEL string into one or more condition lists, fanning out on OR. */ -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); - result.push(andParts.length ? andParts : [branch.trim()]); - } - return result.length ? result : [[]]; -} - -// ─── Trie / DAG ─────────────────────────────────────────────────────────── - -interface TrieNode { - id: string; - condition: string | null; - children: Map; - terminals: RoutingRule[]; -} - -/** - * Normalize a CEL condition token for trie key comparison. - * Collapses whitespace around operators so "a == b" and "a==b" are the same key. - */ -function normalizeCond(cond: string): string { - return cond.trim() - .replace(/\s*(==|!=|>=|<=|>|<)\s*/g, (_, op) => ` ${op} `) - .replace(/\s+/g, " "); -} - -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(); - 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. */ -function mergeSubtrees(root: TrieNode): void { - const registry = new Map(); - const nodeCanon = new Map(); - - function canon(node: TrieNode, seen = new Set()): 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()): 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()): 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()): 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 | 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; } - // Count rules per scope to produce a weighted blend. - const counts = new Map(); - for (const r of rules) 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; -} - -// ─── Intermediate graph representation ──────────────────────────────────── - -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; } - -function collectDAGStructure(root: TrieNode): { lNodes: LNode[]; lEdges: LEdge[] } { - const colorCache = new Map(); - const lNodes: LNode[] = [{ id: "source", kind: "source", data: {}, w: SRC_W, h: SRC_H }]; - const lEdges: LEdge[] = []; - const addedNodes = new Set(["source"]); - const addedEdges = new Set(); - const processed = new Set(); - const chainQueue: { ruleId: string; rule: RoutingRule; sc: string }[] = []; - - function addEdge(src: string, tgt: string, label?: string, color?: string, opts?: Partial) { - 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(); - 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` (certain chain), false when any condition was - * `null` / too complex to evaluate (dynamic / "maybe" chain). */ - function findEntries(startIds: string[], vars: Record): Array<{ id: string; strong: boolean }> { - const results: Array<{ id: string; strong: boolean }> = []; - const visited = new Set(); - - /** 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 certain 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(); - for (const t of rule.targets) { - const vars: Record = {}; - 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 (certain: 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 }; -} - -// ─── Left-to-right BFS layer layout with barycenter crossing minimisation ─ - -function computeLRLayout(lNodes: LNode[], lEdges: LEdge[]): Map { - const widthOf = new Map(lNodes.map((n) => [n.id, n.w])); - const heightOf = new Map(lNodes.map((n) => [n.id, n.h])); - - const childrenOf = new Map(); - const parentsOf = new Map(); - for (const { source, target } of lEdges) { - if (!childrenOf.has(source)) childrenOf.set(source, []); - childrenOf.get(source)!.push(target); - if (!parentsOf.has(target)) parentsOf.set(target, []); - parentsOf.get(target)!.push(source); - } - - // Longest-path depth: shared/merge nodes land at deepest possible column - const depth = new Map(); - const q: Array<{ id: string; d: number }> = [{ id: "source", d: 0 }]; - while (q.length) { - const { id, d } = q.shift()!; - if ((depth.get(id) ?? -1) >= d) continue; - depth.set(id, d); - for (const ch of childrenOf.get(id) ?? []) q.push({ id: ch, d: d + 1 }); - } - - const byLayer = new Map(); - for (const [id, d] of depth) { - if (!byLayer.has(d)) byLayer.set(d, []); - byLayer.get(d)!.push(id); - } - - // X position for each layer = cumulative sum of previous layer widths + H_GAP - const maxDepth = Math.max(0, ...depth.values()); - const layerMaxW = new Map(); - for (const [id, d] of depth) - layerMaxW.set(d, Math.max(layerMaxW.get(d) ?? 0, widthOf.get(id) ?? COND_W)); - - const layerX = new Map(); - let xCursor = 0; - for (let l = 0; l <= maxDepth; l++) { - layerX.set(l, xCursor); - xCursor += (layerMaxW.get(l) ?? COND_W) + H_GAP; - } - - const positions = new Map(); - - // Assign Y positions for one layer given a (possibly reordered) id list - function placeLayer(l: number, ids: string[]) { - const x = layerX.get(l) ?? 0; - const totalH = ids.reduce((s, id) => s + (heightOf.get(id) ?? COND_H), 0) - + Math.max(0, ids.length - 1) * V_GAP; - let y = -totalH / 2; - for (const id of ids) { - positions.set(id, { x, y }); - y += (heightOf.get(id) ?? COND_H) + V_GAP; - } - byLayer.set(l, ids); - } - - // Barycenter of a node's parents (for forward sweep) - function parentBary(id: string): number { - const ps = parentsOf.get(id) ?? []; - if (!ps.length) return 0; - return ps.reduce((s, p) => { - const pos = positions.get(p); - return s + (pos ? pos.y + (heightOf.get(p) ?? COND_H) / 2 : 0); - }, 0) / ps.length; - } - - // Barycenter of a node's children (for backward sweep) - function childBary(id: string): number { - const cs = childrenOf.get(id) ?? []; - if (!cs.length) return Infinity; - return cs.reduce((s, c) => { - const pos = positions.get(c); - return s + (pos ? pos.y + (heightOf.get(c) ?? COND_H) / 2 : 0); - }, 0) / cs.length; - } - - // Initial forward pass - for (let l = 0; l <= maxDepth; l++) { - const ids = (byLayer.get(l) ?? []).slice(); - if (l > 0) ids.sort((a, b) => { - const d = parentBary(a) - parentBary(b); - return d !== 0 ? d : a.localeCompare(b); - }); - placeLayer(l, ids); - } - - // Refinement: alternate forward and backward sweeps (Sugiyama barycenter) - for (let pass = 0; pass < 4; pass++) { - // Forward sweep: sort by parent barycentre - for (let l = 1; l <= maxDepth; l++) { - const ids = (byLayer.get(l) ?? []).slice(); - ids.sort((a, b) => { - const d = parentBary(a) - parentBary(b); - return d !== 0 ? d : a.localeCompare(b); - }); - placeLayer(l, ids); - } - // Backward sweep: sort by child barycentre - for (let l = maxDepth - 1; l >= 0; l--) { - const ids = (byLayer.get(l) ?? []).slice(); - ids.sort((a, b) => { - const d = childBary(a) - childBary(b); - return d !== 0 ? d : a.localeCompare(b); - }); - placeLayer(l, ids); - } - } - - return positions; -} - -// ─── Build React Flow graph ──────────────────────────────────────────────── - -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 = { - 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) { - // Strong chain (certain): animated bezier — we know exactly where it lands. - // Weak chain (dynamic/maybe): sparse dotted bezier, dimmed, not animated. - const weak = le.isChainWeak; - return { - ...base, - type: "simplebezier", - animated: true, - label: le.label, - labelStyle: { fontSize: 10, fill: weak ? `${le.color}99` : le.color }, - labelBgStyle: { fill: "transparent" }, - labelBgPadding: [3, 5] as [number, number], - labelBgBorderRadius: 4, - style: { - stroke: le.color, - strokeWidth: weak ? 1 : 1.5, - strokeDasharray: weak ? "3 6" : "5 4", - opacity: weak ? 0.45 : 1, - }, - }; - } - return { - ...base, - type: "simplebezier", - style: { stroke: le.color ?? "var(--border)", strokeWidth: le.color ? 1.5 : 1 }, - }; - }); - - return { nodes: rfNodes, edges: rfEdges }; -} - -// ─── Custom node components (LR: handles on Left / Right) ───────────────── - -function RFSourceNode() { - return ( -
-
- - Incoming Request -
-

provider · model · headers · params · limits

- -
- ); -} - -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) ?? []; - return ( -
- - - {condition} - - {scopes.length > 0 && ( -
- {scopes.map((sc) => { - const cfg = SCOPE_CONFIG[sc as ScopeKey]; - return cfg ? ( - - {cfg.label} - - ) : null; - })} -
- )} - -
- ); -} - -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 ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - - {rule.chain_rule && ( - - )} - - {/* scope header */} -
- - - {cfg?.label ?? rule.scope} - -
- {rule.chain_rule && ( - - )} - {!rule.enabled && ( - Off - )} -
-
- - {/* rule name */} -
-

{rule.name}

- {rule.priority > 0 && ( -

Priority {rule.priority}

- )} -
- - {/* targets footer */} -
-
- {rule.targets.slice(0, 4).map((t, i) => - t.provider - ? - : - )} - {rule.targets.length > 4 && ( - +{rule.targets.length - 4} - )} -
- - {rule.targets.length} target{rule.targets.length !== 1 ? "s" : ""} - -
- - {/* hover popover */} - {hovered && ( -
- {rule.scope !== "global" && rule.scope_id && ( -
-

- {cfg?.label ?? rule.scope}: - {rule.scope_id} -

-
- )} - {rule.chain_rule && ( -
- -

- Chain rule — resolved provider/model feeds back as the new input and the full scope chain re-evaluates. -

-
- )} -

- {rule.chain_rule ? "Resolved target (new input)" : "Targets"} -

- {rule.targets.map((t, i) => { - const isPassthrough = !t.provider && !t.model; - return ( -
- {t.provider - ? - : - } -
-

- {isPassthrough ? "Passthrough" : (t.provider ? getProviderLabel(t.provider) : t.model)} -

- {t.model && t.provider && ( -

{t.model}

- )} - {isPassthrough && ( -

original provider & model

- )} -
- {multi && ( - - {Math.round(t.weight * 100)}% - - )} -
- ); - })} -
- )} -
- ); -} +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) ──────────────────────────────────────── @@ -833,6 +45,8 @@ const nodeTypes = { rfRule: RFRuleNode, }; +const edgeTypes = { rfChain: RfChainEdge }; + // ─── Main component ──────────────────────────────────────────────────────── export function RoutingTreeView() { @@ -841,7 +55,13 @@ export function RoutingTreeView() { const rules = data?.rules ?? []; // ── Position persistence ─────────────────────────────────────────────── - const [cookies, setCookie] = useCookies([POSITIONS_COOKIE]); + 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(null); + const resetTimeoutRef = useRef | 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( @@ -926,6 +146,84 @@ export function RoutingTreeView() { [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(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(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 | 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([edge.source, edge.target]); + } + + if (!selectedNodeId) return null; + + const childrenOf = new Map(); + const parentsOf = new Map(); + 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([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(""); @@ -989,33 +287,49 @@ export function RoutingTreeView() { 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(() => { - if (!highlightedIds) return nodes; - const active = highlightedIds.size > 0; - return nodes.map((n) => ({ - ...n, - style: { - ...n.style, - opacity: active && !highlightedIds.has(n.id) ? 0.25 : 1, - transition: "opacity 0.15s", - }, - })); - }, [nodes, highlightedIds]); + 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(() => { - if (!highlightedIds) return edges; - const active = highlightedIds.size > 0; - return edges.map((e) => ({ - ...e, - style: { - ...e.style, - opacity: active && !(highlightedIds.has(e.source) && highlightedIds.has(e.target)) ? 0.15 : 1, - transition: "opacity 0.15s", - }, - })); - }, [edges, highlightedIds]); + const h = activeHighlightIds; + const dimOpacity = (selectedNodeId || selectedEdgeId) ? 0.10 : 0.20; + 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 ( @@ -1051,9 +365,14 @@ export function RoutingTreeView() { 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: 0.05 }} + fitViewOptions={{ padding: FIT_VIEW_PADDING }} defaultViewport={positionsRestored ? (initialCookie?.viewport ?? { x: 0, y: 0, zoom: 1 }) : undefined} onMoveEnd={handleMoveEnd} nodesDraggable={true} @@ -1082,10 +401,10 @@ export function RoutingTreeView() {

Routing Tree

{search - ? highlightedIds && highlightedIds.size > 0 - ? `${matchCount} rule${matchCount !== 1 ? "s" : ""}` - : "no match" - : `${rules.length} rule${rules.length !== 1 ? "s" : ""}`} + ? highlightedIds && highlightedIds.size > 0 + ? `${matchCount} rule${matchCount !== 1 ? "s" : ""}` + : "no match" + : `${rules.length} rule${rules.length !== 1 ? "s" : ""}`}

@@ -1098,33 +417,87 @@ export function RoutingTreeView() { className="h-8 w-56 pl-8 text-sm" />
+
+
{/* Scope + edge legend — floats below */}
{SCOPE_ORDER.map((s) => (
- {SCOPE_CONFIG[s].label} + {SCOPE_CONFIG[s].label}
))}
- Chain rule + Chain rule
- {/* Chain edge styles */} + {/* Chain edge styles — both dashed (long = static, short = dynamic); arrows at path midpoint */}
- - + + + - Certain chain + Static chain + + + + + + Re-entry point is fully proven by static analysis — every condition on the path evaluated to a known value. + +
- - + + + - Maybe chain + Dynamic chain + + + + + + Re-entry point is a conditional — one or more conditions on the path are not fully evaluated at build time. + +
diff --git a/ui/components/ui/asyncMultiselect.tsx b/ui/components/ui/asyncMultiselect.tsx index 8d8ccd6f60..32d25f4da5 100644 --- a/ui/components/ui/asyncMultiselect.tsx +++ b/ui/components/ui/asyncMultiselect.tsx @@ -1,6 +1,6 @@ "use client"; -import { CheckIcon, ChevronDown, PlusCircle, PlusIcon, XIcon } from "lucide-react"; +import { CheckIcon, ChevronDown, PlusIcon, XIcon } from "lucide-react"; import React, { KeyboardEventHandler, useCallback, useEffect, useRef, useState } from "react"; import { ClearIndicatorProps, @@ -27,7 +27,6 @@ import { } from "react-select"; import AsyncCreatableSelect from "react-select/async-creatable"; import { useDebouncedFunction } from "../../hooks/useDebounce"; -import { Checkbox } from "./checkbox"; import { Icons } from "./icons"; import { Label } from "./label"; import { @@ -36,10 +35,8 @@ import { CustomDropdownIndicatorProps, CustomOptionProps, CustomPlaceholderProps, - EvaluatorGroup, - EvaluatorOption, Option, - OptionGroup, + OptionGroup } from "./multiselectUtils"; import { Separator } from "./separator"; import { cn, radixDialogOnBlurWorkaround } from "./utils"; @@ -459,7 +456,7 @@ export function AsyncMultiSelect(props: AsyncMultiSelectProps) { noOptionsMessage: () => cn("text-content-disabled flex items-center justify-center text-sm", props.noOptionsMessageClassName), indicatorsContainer: () => "h-8", }} - minMenuHeight={400} + minMenuHeight={160} components={{ ClearIndicator: CustomClearIndicator, Control: CustomControl, @@ -533,128 +530,6 @@ export function MultiSelectInput(props: AsyncMultiSelectProps) { ); } -interface EvaluatorMultiSelectProps extends Omit>, "onChange"> { - keepTags?: string[]; - filterTags?: string[]; - typeFilter?: string[]; - filter?: (options: (EvaluatorOption | EvaluatorGroup)[]) => (EvaluatorOption | EvaluatorGroup)[]; - options?: any; - onChange?: (items: EvaluatorOption[]) => void; -} - -export function EvaluatorMultiSelect(props: EvaluatorMultiSelectProps) { - const shouldFilterEvaluatorOnTags = (option: EvaluatorOption) => { - const tags = (option as any)?.tags || []; - const type = (option as any)?.type; - - if (props.keepTags?.length) { - for (const tag of props.keepTags) { - if (tags.some((t: any) => t.label === tag)) { - return true; - } - } - return false; - } - - if (props.filterTags?.length || props.typeFilter?.length) { - if (props.typeFilter?.includes(type)) { - return false; - } - - for (const tag of props.filterTags || []) { - if (tags.some((t: any) => t.label === tag)) { - return false; - } - } - } - return true; - }; - - const processedOptions = props.options - ? Array.isArray(props.options) - ? props.options.map((group) => ({ - ...group, - options: group.options.filter(shouldFilterEvaluatorOnTags), - })) - : props.options - : undefined; - - const filteredOptions = props.filter ? props.filter(processedOptions as (EvaluatorOption | EvaluatorGroup)[]) : processedOptions; - - return ( - { - props.onChange?.(items as EvaluatorOption[]); - }} - isNonAsync - defaultOptions={filteredOptions} - value={props.value} - views={{ - option: (optionProps) => { - return ( - -
-
-
- {optionProps.data.label} -
- {optionProps.data.meta?.description && ( - {optionProps.data.meta.description} - )} -
- -
-
- ); - }, - groupHeading: (groupProps) => { - const data = groupProps.data as unknown as EvaluatorGroup; - return ( - -
- {data.icon &&
{data.icon}
} - {data.label} -
-
- ); - }, - group: (groupProps) => { - return ; - }, - multiValue: (multiValueProps) => { - return ( - -
- {multiValueProps.data.meta?.icon &&
{multiValueProps.data.meta.icon}
} - {multiValueProps.data.label} -
-
- ); - }, - }} - /> - ); -} function CustomOption(props: OptionProps> & { selectProps: CustomOptionProps & CustomComponentsProps }) { const { Option } = components; diff --git a/ui/package-lock.json b/ui/package-lock.json index b3adc8aaca..54e1619e82 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@base-ui/react": "1.2.0", "@bprogress/next": "3.2.12", + "@dagrejs/dagre": "3.0.0", "@dnd-kit/helpers": "0.3.2", "@dnd-kit/react": "0.3.2", "@hookform/resolvers": "5.2.1", @@ -335,6 +336,21 @@ "react-dom": ">=18.0.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, "node_modules/@date-fns/tz": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", @@ -13784,24 +13800,6 @@ } } }, - "node_modules/vitest/node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/watchpack": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", diff --git a/ui/package.json b/ui/package.json index dcd6210e61..960dd30715 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "dependencies": { "@base-ui/react": "1.2.0", "@bprogress/next": "3.2.12", + "@dagrejs/dagre": "3.0.0", "@dnd-kit/helpers": "0.3.2", "@dnd-kit/react": "0.3.2", "@hookform/resolvers": "5.2.1",