diff --git a/dashboard/src/components/RoutingMap.tsx b/dashboard/src/components/RoutingMap.tsx
new file mode 100644
index 0000000..39e3748
--- /dev/null
+++ b/dashboard/src/components/RoutingMap.tsx
@@ -0,0 +1,149 @@
+import {
+ Background,
+ Controls,
+ Handle,
+ MiniMap,
+ type NodeProps,
+ type NodeTypes,
+ Position,
+ ReactFlow,
+ useEdgesState,
+ useNodesState,
+} from "@xyflow/react";
+import { type CSSProperties, useEffect, useMemo } from "react";
+import {
+ buildRoutingGraph,
+ type RoutingGraphChat,
+ type RoutingGraphConfig,
+ type RoutingGraphEdge,
+ type RoutingGraphEventOption,
+ type RoutingGraphNode,
+ type RoutingGraphUser,
+} from "./routingGraph";
+
+export interface RoutingMapProps {
+ appName: string;
+ routing: RoutingGraphConfig;
+ eventOptions: RoutingGraphEventOption[];
+ chats?: RoutingGraphChat[];
+ users?: RoutingGraphUser[];
+ className?: string;
+ style?: CSSProperties;
+}
+
+const nodeTypes = {
+ routing: RoutingMapNode,
+} satisfies NodeTypes;
+
+const hiddenHandleStyle = {
+ opacity: 0,
+ pointerEvents: "none",
+} satisfies CSSProperties;
+
+export function RoutingMap({
+ appName,
+ routing,
+ eventOptions,
+ chats,
+ users,
+ className,
+ style,
+}: RoutingMapProps) {
+ const { nodes, edges } = useMemo(
+ () => buildRoutingGraph({ appName, routing, eventOptions, chats, users }),
+ [appName, routing, eventOptions, chats, users],
+ );
+
+ const flowNodes = useMemo(
+ () =>
+ nodes.map((node) => ({
+ ...node,
+ type: "routing",
+ })),
+ [nodes],
+ );
+
+ const flowEdges = useMemo(
+ () =>
+ edges.map((edge) => ({
+ ...edge,
+ selectable: false,
+ reconnectable: false,
+ className:
+ edge.data?.kind === "default"
+ ? "routing-map-edge default"
+ : "routing-map-edge",
+ style:
+ edge.data?.kind === "default"
+ ? { strokeDasharray: "6 5" }
+ : undefined,
+ })),
+ [edges],
+ );
+ const [graphNodes, setGraphNodes, onNodesChange] =
+ useNodesState
([]);
+ const [graphEdges, setGraphEdges, onEdgesChange] =
+ useEdgesState([]);
+
+ useEffect(() => {
+ setGraphNodes(flowNodes);
+ }, [flowNodes, setGraphNodes]);
+
+ useEffect(() => {
+ setGraphEdges(flowEdges);
+ }, [flowEdges, setGraphEdges]);
+
+ return (
+
+
+ nodes={graphNodes}
+ edges={graphEdges}
+ nodeTypes={nodeTypes}
+ onNodesChange={onNodesChange}
+ onEdgesChange={onEdgesChange}
+ defaultViewport={{ x: 80, y: 70, zoom: 0.92 }}
+ minZoom={0.35}
+ maxZoom={1.35}
+ nodesDraggable
+ nodesConnectable={false}
+ edgesReconnectable={false}
+ deleteKeyCode={null}
+ multiSelectionKeyCode={null}
+ selectionKeyCode={null}
+ proOptions={{ hideAttribution: true }}
+ >
+
+
+
+
+
+ );
+}
+
+function RoutingMapNode({ data }: NodeProps) {
+ return (
+
+
+ {data.label}
+ {data.subtitle}
+
+
+ );
+}
diff --git a/dashboard/src/components/RoutingMapPanel.tsx b/dashboard/src/components/RoutingMapPanel.tsx
new file mode 100644
index 0000000..9ffeb9c
--- /dev/null
+++ b/dashboard/src/components/RoutingMapPanel.tsx
@@ -0,0 +1,54 @@
+import useSWR from "swr";
+import { errMessage } from "../lib/http";
+import { RoutingMap } from "./RoutingMap";
+import {
+ type RoutingGraphChat,
+ type RoutingGraphConfig,
+ type RoutingGraphEventOption,
+ type RoutingGraphUser,
+} from "./routingGraph";
+import { Spinner } from "./Spinner";
+
+export interface RoutingMapPanelProps {
+ appName: string;
+ eventOptions: RoutingGraphEventOption[];
+}
+
+export function RoutingMapPanel({
+ appName,
+ eventOptions,
+}: RoutingMapPanelProps) {
+ const url = `/api/apps/${appName}/routing`;
+ const { data: routing, error } = useSWR(url);
+ const { data: chats } = useSWR(
+ `/api/apps/${appName}/chats`,
+ { shouldRetryOnError: false },
+ );
+ const { data: users } = useSWR(
+ `/api/apps/${appName}/users`,
+ { shouldRetryOnError: false },
+ );
+
+ return (
+
+
+
+
routing map
+
{appName}
+
+
+
+ {error &&
Failed to load: {errMessage(error)}
}
+ {!error && !routing &&
}
+ {routing && (
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/routingGraph.ts b/dashboard/src/components/routingGraph.ts
new file mode 100644
index 0000000..313d4fd
--- /dev/null
+++ b/dashboard/src/components/routingGraph.ts
@@ -0,0 +1,406 @@
+import { type Edge, MarkerType, type Node, Position } from "@xyflow/react";
+
+export type RoutingGraphDestinationKind = "chat" | "dm";
+
+export interface RoutingGraphDestination {
+ kind: RoutingGraphDestinationKind;
+ target: string;
+}
+
+export interface RoutingGraphRule {
+ match: string;
+ events: string[];
+ destinations: RoutingGraphDestination[];
+}
+
+export interface RoutingGraphConfig {
+ rules: RoutingGraphRule[];
+ default_destinations: RoutingGraphDestination[];
+}
+
+export interface RoutingGraphEventOption {
+ value: string;
+ label: string;
+}
+
+export interface RoutingGraphChat {
+ chat_id: string;
+ name: string;
+}
+
+export interface RoutingGraphUser {
+ open_id: string;
+ name: string;
+}
+
+export type RoutingGraphNodeKind =
+ | "source"
+ | "event"
+ | "rule"
+ | "destination"
+ | "default"
+ | "drop";
+
+export type RoutingGraphColumn = "source" | "event" | "rule" | "destination";
+
+export interface RoutingGraphColumnDefinition {
+ key: RoutingGraphColumn;
+ label: string;
+ x: number;
+}
+
+export interface RoutingGraphNodeKindDefinition {
+ kind: RoutingGraphNodeKind;
+ column: RoutingGraphColumn;
+ label: string;
+}
+
+export interface RoutingGraphNodeData extends Record {
+ label: string;
+ subtitle: string;
+ kind: RoutingGraphNodeKind;
+ column: RoutingGraphColumn;
+}
+
+export interface RoutingGraphEdgeData extends Record {
+ kind: "routing" | "default";
+}
+
+export type RoutingGraphNode = Node;
+export type RoutingGraphEdge = Edge;
+
+export interface BuildRoutingGraphArgs {
+ appName: string;
+ routing: RoutingGraphConfig;
+ eventOptions: RoutingGraphEventOption[];
+ chats?: RoutingGraphChat[];
+ users?: RoutingGraphUser[];
+}
+
+export interface RoutingGraphResult {
+ nodes: RoutingGraphNode[];
+ edges: RoutingGraphEdge[];
+}
+
+export const ROUTING_GRAPH_COLUMNS = [
+ { key: "source", label: "Source", x: 0 },
+ { key: "event", label: "Event", x: 210 },
+ { key: "rule", label: "Rule", x: 430 },
+ { key: "destination", label: "Destination", x: 660 },
+] as const satisfies readonly RoutingGraphColumnDefinition[];
+
+export const ROUTING_GRAPH_NODE_KINDS = [
+ { kind: "source", column: "source", label: "Source" },
+ { kind: "event", column: "event", label: "Event" },
+ { kind: "rule", column: "rule", label: "Rule" },
+ { kind: "default", column: "rule", label: "Default" },
+ { kind: "destination", column: "destination", label: "Destination" },
+ { kind: "drop", column: "destination", label: "Dropped" },
+] as const satisfies readonly RoutingGraphNodeKindDefinition[];
+
+const ROW_GAP = {
+ event: 125,
+ rule: 145,
+ destination: 105,
+};
+
+const ALL_EVENTS = "*";
+
+export function buildRoutingGraph({
+ appName,
+ routing,
+ eventOptions,
+ chats,
+ users,
+}: BuildRoutingGraphArgs): RoutingGraphResult {
+ const nodes: RoutingGraphNode[] = [];
+ const edges: RoutingGraphEdge[] = [];
+ const edgeKeys = new Set();
+ const eventLabels = new Map(
+ eventOptions
+ .map((event) => ({
+ value: event.value.trim(),
+ label: event.label.trim() || event.value.trim(),
+ }))
+ .filter((event) => event.value.length > 0)
+ .map((event) => [event.value, event.label]),
+ );
+ const chatLabels = new Map(
+ (chats ?? [])
+ .map((chat) => ({
+ id: chat.chat_id.trim(),
+ name: chat.name.trim() || chat.chat_id.trim(),
+ }))
+ .filter((chat) => chat.id.length > 0)
+ .map((chat) => [chat.id, chat.name]),
+ );
+ const userLabels = new Map(
+ (users ?? [])
+ .map((user) => ({
+ id: user.open_id.trim(),
+ name: user.name.trim() || user.open_id.trim(),
+ }))
+ .filter((user) => user.id.length > 0)
+ .map((user) => [user.id, user.name]),
+ );
+ const destinationIds = new Map();
+ const usedDestinationRows = new Set();
+ let edgeCount = 0;
+
+ const addNode = (
+ id: string,
+ kind: RoutingGraphNodeKind,
+ label: string,
+ subtitle: string,
+ y: number,
+ ) => {
+ const column = columnForKind(kind);
+ nodes.push({
+ id,
+ position: { x: xForColumn(column), y },
+ sourcePosition: Position.Right,
+ targetPosition: Position.Left,
+ data: { label, subtitle, kind, column },
+ });
+ };
+
+ const addEdge = (
+ source: string,
+ target: string,
+ kind: RoutingGraphEdgeData["kind"] = "routing",
+ ) => {
+ const edgeKey = `${source}->${target}:${kind}`;
+ if (edgeKeys.has(edgeKey)) return;
+ edgeKeys.add(edgeKey);
+ edgeCount += 1;
+ edges.push({
+ id: `edge-${edgeCount}`,
+ source,
+ target,
+ type: "smoothstep",
+ markerEnd: { type: MarkerType.ArrowClosed },
+ data: { kind },
+ });
+ };
+
+ const normalizedRules = routing.rules.map((rule) => ({
+ ...rule,
+ events: normalizeEvents(rule.events),
+ }));
+ const eventValues = unique(normalizedRules.flatMap((rule) => rule.events));
+ const eventIds = new Map(
+ eventValues.map((event, index) => [event, eventNodeId(event, index + 1)]),
+ );
+ const sourceY = Math.max(0, (Math.max(eventValues.length, 1) - 1) * 55);
+ const sourceId = "source";
+
+ addNode(
+ sourceId,
+ "source",
+ titleCase(appName) || "App",
+ "webhook events",
+ sourceY,
+ );
+
+ eventValues.forEach((event, index) => {
+ const eventId = eventIds.get(event) ?? eventNodeId(event, index + 1);
+ addNode(
+ eventId,
+ "event",
+ event === ALL_EVENTS ? "All events" : eventLabels.get(event) || event,
+ event === ALL_EVENTS ? "no event filter" : event,
+ index * ROW_GAP.event,
+ );
+ addEdge(sourceId, eventId);
+ });
+
+ if (routing.rules.length === 0) {
+ addNode("no-rules", "drop", "No rules", "unmatched events use defaults", 0);
+ addEdge(sourceId, "no-rules");
+ }
+
+ normalizedRules.forEach((rule, index) => {
+ const ruleId = `rule-${index + 1}`;
+ const match = rule.match.trim() || "*";
+
+ addNode(
+ ruleId,
+ "rule",
+ `Rule ${index + 1}`,
+ `${match} | ${eventSummary(rule.events, eventLabels)}`,
+ index * ROW_GAP.rule,
+ );
+
+ rule.events.forEach((event) => {
+ addEdge(eventIds.get(event) ?? eventNodeId(event, 0), ruleId);
+ });
+
+ const destinations = nonEmptyDestinations(rule.destinations);
+ if (destinations.length === 0) {
+ const dropId = `${ruleId}-drop`;
+ addNode(
+ dropId,
+ "drop",
+ "No destination",
+ "matching events are dropped",
+ index * ROW_GAP.rule,
+ );
+ addEdge(ruleId, dropId);
+ return;
+ }
+
+ destinations.forEach((destination) => {
+ const destinationId = ensureDestinationNode(
+ destination,
+ index * ROW_GAP.rule,
+ );
+ addEdge(ruleId, destinationId);
+ });
+ });
+
+ const defaultY =
+ Math.max(
+ eventValues.length * ROW_GAP.event,
+ Math.max(routing.rules.length, 1) * ROW_GAP.rule,
+ ) + 30;
+ const defaultId = "default";
+
+ addNode(defaultId, "default", "Default", "when no rule matches", defaultY);
+ addEdge(sourceId, defaultId, "default");
+
+ const defaultDestinations = nonEmptyDestinations(
+ routing.default_destinations,
+ );
+ if (defaultDestinations.length === 0) {
+ addNode(
+ "default-drop",
+ "drop",
+ "No default",
+ "unmatched events are dropped",
+ defaultY,
+ );
+ addEdge(defaultId, "default-drop", "default");
+ } else {
+ defaultDestinations.forEach((destination) => {
+ const destinationId = ensureDestinationNode(destination, defaultY);
+ addEdge(defaultId, destinationId, "default");
+ });
+ }
+
+ return { nodes, edges };
+
+ function ensureDestinationNode(
+ destination: RoutingGraphDestination,
+ preferredY: number,
+ ): string {
+ const target = destination.target.trim();
+ const key = `${destination.kind}:${target}`;
+ const existing = destinationIds.get(key);
+ if (existing) return existing;
+
+ const id = `destination-${destinationIds.size + 1}`;
+ const y = nextAvailableDestinationY(preferredY);
+ destinationIds.set(key, id);
+
+ addNode(
+ id,
+ "destination",
+ destinationLabel(destination, chatLabels, userLabels),
+ target,
+ y,
+ );
+
+ return id;
+ }
+
+ function nextAvailableDestinationY(preferredY: number): number {
+ let row = Math.max(0, Math.round(preferredY / ROW_GAP.destination));
+ while (usedDestinationRows.has(row)) {
+ row += 1;
+ }
+ usedDestinationRows.add(row);
+ return row * ROW_GAP.destination;
+ }
+}
+
+function columnForKind(kind: RoutingGraphNodeKind): RoutingGraphColumn {
+ return (
+ ROUTING_GRAPH_NODE_KINDS.find((definition) => definition.kind === kind)
+ ?.column ?? "destination"
+ );
+}
+
+function xForColumn(column: RoutingGraphColumn): number {
+ return (
+ ROUTING_GRAPH_COLUMNS.find((definition) => definition.key === column)?.x ??
+ 0
+ );
+}
+
+function eventNodeId(event: string, index: number): string {
+ return `event-${index}-${sanitizeId(event) || "all"}`;
+}
+
+function sanitizeId(value: string): string {
+ return value.replaceAll(/[^a-zA-Z0-9_-]/g, "_");
+}
+
+function titleCase(value: string): string {
+ return value
+ .split(/[-_\s]+/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function eventSummary(
+ events: string[],
+ eventLabels: Map,
+): string {
+ if (events.length === 0 || events.includes(ALL_EVENTS)) return "all events";
+ return events.map((event) => eventLabels.get(event) || event).join(", ");
+}
+
+function destinationLabel(
+ destination: RoutingGraphDestination,
+ chatLabels: Map,
+ userLabels: Map,
+): string {
+ const target = destination.target.trim();
+ if (destination.kind === "chat") {
+ return chatLabels.get(target) || "Group chat";
+ }
+ return userLabels.get(target) || "Direct message";
+}
+
+function normalizeEvents(events: string[]): string[] {
+ const normalized = unique(
+ events.map((event) => event.trim()).filter((event) => event.length > 0),
+ );
+ if (normalized.length === 0 || normalized.includes(ALL_EVENTS)) {
+ return [ALL_EVENTS];
+ }
+ return normalized;
+}
+
+function nonEmptyDestinations(
+ destinations: RoutingGraphDestination[],
+): RoutingGraphDestination[] {
+ const seen = new Set();
+ return destinations
+ .map((destination) => ({
+ kind: destination.kind,
+ target: destination.target.trim(),
+ }))
+ .filter((destination) => {
+ if (destination.target.length === 0) return false;
+ const key = `${destination.kind}:${destination.target}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+function unique(items: T[]): T[] {
+ return [...new Set(items)];
+}
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx
index eda964b..3fe83c7 100644
--- a/dashboard/src/main.tsx
+++ b/dashboard/src/main.tsx
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router";
import { SWRConfig } from "swr";
import { App } from "./App.tsx";
import { swrConfig } from "./lib/http.ts";
+import "@xyflow/react/dist/style.css";
import "./styles.css";
const root = document.getElementById("root");
diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css
index 88feaf9..e78cd05 100644
--- a/dashboard/src/styles.css
+++ b/dashboard/src/styles.css
@@ -703,6 +703,124 @@ button.signout {
background: color-mix(in srgb, canvastext 8%, transparent);
}
+.routing-map-section {
+ margin-bottom: 1.75rem;
+}
+
+.routing-map {
+ overflow: hidden;
+ border: 1px solid color-mix(in srgb, canvastext 13%, transparent);
+ border-radius: 8px;
+ background: color-mix(in srgb, canvastext 2%, transparent);
+}
+
+.routing-map .react-flow__pane {
+ cursor: grab;
+}
+
+.routing-map .react-flow__pane:active {
+ cursor: grabbing;
+}
+
+.routing-map-node {
+ box-sizing: border-box;
+ display: grid;
+ gap: 0.22rem;
+ width: 11.5rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid color-mix(in srgb, canvastext 18%, transparent);
+ border-radius: 8px;
+ background: canvas;
+ color: canvastext;
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08);
+}
+
+.routing-map-node-source {
+ border-color: color-mix(in srgb, #2563eb 48%, transparent);
+}
+
+.routing-map-node-event {
+ border-color: color-mix(in srgb, #0891b2 45%, transparent);
+}
+
+.routing-map-node-rule {
+ border-color: color-mix(in srgb, #8b5cf6 42%, transparent);
+}
+
+.routing-map-node-default {
+ border-style: dashed;
+ border-color: color-mix(in srgb, #f59e0b 55%, transparent);
+}
+
+.routing-map-node-destination {
+ border-color: color-mix(in srgb, #16a34a 45%, transparent);
+}
+
+.routing-map-node-drop {
+ border-style: dashed;
+ border-color: color-mix(in srgb, #ef4444 50%, transparent);
+ opacity: 0.86;
+}
+
+.react-flow__node.selected .routing-map-node {
+ box-shadow:
+ 0 0 0 2px color-mix(in srgb, canvastext 12%, transparent),
+ 0 8px 22px rgba(0, 0, 0, 0.08);
+}
+
+.routing-map-node-label {
+ overflow: hidden;
+ font-size: 0.82rem;
+ font-weight: 650;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.routing-map-node-subtitle {
+ overflow: hidden;
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 0.66rem;
+ line-height: 1.25;
+ opacity: 0.58;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.routing-map-edge {
+ stroke: color-mix(in srgb, canvastext 52%, transparent);
+}
+
+.routing-map-edge.default {
+ stroke-dasharray: 6 5;
+}
+
+.routing-map .react-flow__edge-path {
+ stroke-width: 1.6;
+}
+
+.routing-map .react-flow__controls,
+.routing-map .routing-map-minimap {
+ border: 1px solid color-mix(in srgb, canvastext 14%, transparent);
+ background: canvas;
+ color: canvastext;
+ box-shadow: none;
+}
+
+.routing-map .react-flow__controls-button {
+ border-bottom-color: color-mix(in srgb, canvastext 12%, transparent);
+ background: canvas;
+ color: canvastext;
+}
+
+.routing-map .react-flow__controls-button:hover {
+ background: color-mix(in srgb, canvastext 7%, transparent);
+}
+
+.routing-map .react-flow__minimap-mask {
+ fill: color-mix(in srgb, canvastext 12%, transparent);
+}
+
.routing-section + .routing-section {
margin-top: 1.75rem;
}
diff --git a/dashboard/src/tabs/GitHub.tsx b/dashboard/src/tabs/GitHub.tsx
index a3925dc..f78741a 100644
--- a/dashboard/src/tabs/GitHub.tsx
+++ b/dashboard/src/tabs/GitHub.tsx
@@ -1,7 +1,8 @@
import { LarkBinding } from "../components/LarkBinding";
-import { type EventOption, RoutingEditor } from "../components/RoutingEditor";
+import { RoutingMapPanel } from "../components/RoutingMapPanel";
+import type { RoutingGraphEventOption } from "../components/routingGraph";
-const GITHUB_EVENTS: EventOption[] = [
+const GITHUB_EVENTS: RoutingGraphEventOption[] = [
{ value: "pull_request", label: "Pull requests" },
{ value: "issues", label: "Issues (alert labels)" },
{ value: "workflow_run", label: "CI failures" },
@@ -14,7 +15,7 @@ export function GitHub() {
);
}
diff --git a/dashboard/src/tabs/Gitlab.tsx b/dashboard/src/tabs/Gitlab.tsx
index 68cb5e9..4be39fd 100644
--- a/dashboard/src/tabs/Gitlab.tsx
+++ b/dashboard/src/tabs/Gitlab.tsx
@@ -1,7 +1,8 @@
import { LarkBinding } from "../components/LarkBinding";
-import { type EventOption, RoutingEditor } from "../components/RoutingEditor";
+import { RoutingMapPanel } from "../components/RoutingMapPanel";
+import type { RoutingGraphEventOption } from "../components/routingGraph";
-const GITLAB_EVENTS: EventOption[] = [
+const GITLAB_EVENTS: RoutingGraphEventOption[] = [
{ value: "merge_request", label: "Merge requests" },
{ value: "issue", label: "Issues (alert labels)" },
{ value: "pipeline", label: "Pipeline failures" },
@@ -14,7 +15,7 @@ export function Gitlab() {
);
}
diff --git a/dashboard/src/tabs/Linear.tsx b/dashboard/src/tabs/Linear.tsx
index 7580df8..efe7ca9 100644
--- a/dashboard/src/tabs/Linear.tsx
+++ b/dashboard/src/tabs/Linear.tsx
@@ -7,15 +7,16 @@ import useSWR from "swr";
import useSWRMutation from "swr/mutation";
import { Checkbox } from "../components/Checkbox";
import { LarkBinding } from "../components/LarkBinding";
-import { type EventOption, RoutingEditor } from "../components/RoutingEditor";
+import { RoutingMapPanel } from "../components/RoutingMapPanel";
import { Select } from "../components/Select";
+import type { RoutingGraphEventOption } from "../components/routingGraph";
import { errMessage, mutateRequest } from "../lib/http";
type Feedback = { tone: "ok" | "error"; text: string } | null;
// Linear routes by team key (the identifier prefix, e.g. `ENG`); a `*` rule or a
// default destination catches everything.
-const LINEAR_EVENTS: EventOption[] = [
+const LINEAR_EVENTS: RoutingGraphEventOption[] = [
{ value: "issue", label: "Issues (create / update)" },
{ value: "comment", label: "Comments" },
];
@@ -481,12 +482,7 @@ export function Linear() {