first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View 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,
});

View 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>
);
}

View 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, " ");
}

View 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;

View 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 };
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
import { Handle, type HandleProps } from "@xyflow/react";
/** Visual diameter; React Flows 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}
/>
);
}

View 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 &amp; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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("|");
}

View 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);

View 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>
);
}