@proofhound/web-ui 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/components/navigation/link.d.ts +15 -0
  2. package/dist/components/navigation/link.d.ts.map +1 -0
  3. package/dist/components/navigation/link.js +23 -0
  4. package/dist/components/navigation/link.js.map +1 -0
  5. package/dist/contracts/index.d.ts +2 -0
  6. package/dist/contracts/index.d.ts.map +1 -1
  7. package/dist/contracts/index.js.map +1 -1
  8. package/dist/hooks/annotation.d.ts +37 -16
  9. package/dist/hooks/annotation.d.ts.map +1 -1
  10. package/dist/hooks/canary-release.d.ts +62 -37
  11. package/dist/hooks/canary-release.d.ts.map +1 -1
  12. package/dist/hooks/dataset.d.ts +101 -0
  13. package/dist/hooks/dataset.d.ts.map +1 -1
  14. package/dist/hooks/dataset.js +27 -0
  15. package/dist/hooks/dataset.js.map +1 -1
  16. package/dist/hooks/optimization.d.ts +1 -1
  17. package/dist/hooks/production-release.d.ts +8 -4
  18. package/dist/hooks/production-release.d.ts.map +1 -1
  19. package/dist/hooks/prompt.d.ts +149 -38
  20. package/dist/hooks/prompt.d.ts.map +1 -1
  21. package/dist/hooks/prompt.js +20 -0
  22. package/dist/hooks/prompt.js.map +1 -1
  23. package/dist/hooks/release-line.d.ts +2522 -72
  24. package/dist/hooks/release-line.d.ts.map +1 -1
  25. package/dist/hooks/release-line.js +125 -0
  26. package/dist/hooks/release-line.js.map +1 -1
  27. package/dist/hooks/run-result.d.ts +9 -6
  28. package/dist/hooks/run-result.d.ts.map +1 -1
  29. package/dist/hooks/run-result.js +2 -1
  30. package/dist/hooks/run-result.js.map +1 -1
  31. package/dist/hooks/use-router.d.ts +17 -0
  32. package/dist/hooks/use-router.d.ts.map +1 -0
  33. package/dist/hooks/use-router.js +30 -0
  34. package/dist/hooks/use-router.js.map +1 -0
  35. package/dist/i18n/index.d.ts +652 -160
  36. package/dist/i18n/index.d.ts.map +1 -1
  37. package/dist/i18n/index.js +652 -160
  38. package/dist/i18n/index.js.map +1 -1
  39. package/dist/lib/releases/release-line-model.d.ts +8 -2
  40. package/dist/lib/releases/release-line-model.d.ts.map +1 -1
  41. package/dist/lib/releases/release-line-model.js +66 -29
  42. package/dist/lib/releases/release-line-model.js.map +1 -1
  43. package/dist/providers/index.d.ts +1 -0
  44. package/dist/providers/index.d.ts.map +1 -1
  45. package/dist/providers/index.js +1 -0
  46. package/dist/providers/index.js.map +1 -1
  47. package/dist/providers/navigation-provider.d.ts +27 -0
  48. package/dist/providers/navigation-provider.d.ts.map +1 -0
  49. package/dist/providers/navigation-provider.js +17 -0
  50. package/dist/providers/navigation-provider.js.map +1 -0
  51. package/dist/providers/proofhound-web-provider.d.ts.map +1 -1
  52. package/dist/providers/proofhound-web-provider.js +2 -1
  53. package/dist/providers/proofhound-web-provider.js.map +1 -1
  54. package/dist/screens/annotations/annotation-detail-page.d.ts.map +1 -1
  55. package/dist/screens/annotations/annotation-detail-page.js +4 -3
  56. package/dist/screens/annotations/annotation-detail-page.js.map +1 -1
  57. package/dist/screens/annotations/annotation-new-page.d.ts.map +1 -1
  58. package/dist/screens/annotations/annotation-new-page.js +216 -51
  59. package/dist/screens/annotations/annotation-new-page.js.map +1 -1
  60. package/dist/screens/annotations/annotation-task-model.d.ts +3 -2
  61. package/dist/screens/annotations/annotation-task-model.d.ts.map +1 -1
  62. package/dist/screens/annotations/annotation-task-model.js +5 -4
  63. package/dist/screens/annotations/annotation-task-model.js.map +1 -1
  64. package/dist/screens/annotations/annotation-ui.d.ts.map +1 -1
  65. package/dist/screens/annotations/annotation-ui.js +9 -4
  66. package/dist/screens/annotations/annotation-ui.js.map +1 -1
  67. package/dist/screens/annotations/annotations-list-page.js +3 -3
  68. package/dist/screens/annotations/annotations-list-page.js.map +1 -1
  69. package/dist/screens/connectors/connector-detail-page.d.ts.map +1 -1
  70. package/dist/screens/connectors/connector-detail-page.js +8 -4
  71. package/dist/screens/connectors/connector-detail-page.js.map +1 -1
  72. package/dist/screens/connectors/connector-form-page.js +3 -3
  73. package/dist/screens/connectors/connector-form-page.js.map +1 -1
  74. package/dist/screens/connectors/connector-ui.d.ts +6 -0
  75. package/dist/screens/connectors/connector-ui.d.ts.map +1 -1
  76. package/dist/screens/connectors/connector-ui.js +7 -1
  77. package/dist/screens/connectors/connector-ui.js.map +1 -1
  78. package/dist/screens/connectors/connectors-list-page.d.ts.map +1 -1
  79. package/dist/screens/connectors/connectors-list-page.js +8 -7
  80. package/dist/screens/connectors/connectors-list-page.js.map +1 -1
  81. package/dist/screens/dashboard/dashboard-screen.d.ts.map +1 -1
  82. package/dist/screens/dashboard/dashboard-screen.js +29 -17
  83. package/dist/screens/dashboard/dashboard-screen.js.map +1 -1
  84. package/dist/screens/datasets/dataset-detail-page.js +1 -1
  85. package/dist/screens/datasets/dataset-detail-page.js.map +1 -1
  86. package/dist/screens/datasets/dataset-mappers.js +1 -1
  87. package/dist/screens/datasets/dataset-mappers.js.map +1 -1
  88. package/dist/screens/datasets/dataset-types.d.ts +1 -1
  89. package/dist/screens/datasets/dataset-types.d.ts.map +1 -1
  90. package/dist/screens/datasets/dataset-ui.d.ts +1 -1
  91. package/dist/screens/datasets/dataset-ui.d.ts.map +1 -1
  92. package/dist/screens/datasets/dataset-ui.js +2 -2
  93. package/dist/screens/datasets/dataset-ui.js.map +1 -1
  94. package/dist/screens/datasets/dataset-upload-page.js +2 -2
  95. package/dist/screens/datasets/dataset-upload-page.js.map +1 -1
  96. package/dist/screens/datasets/datasets-list-page.d.ts.map +1 -1
  97. package/dist/screens/datasets/datasets-list-page.js +37 -26
  98. package/dist/screens/datasets/datasets-list-page.js.map +1 -1
  99. package/dist/screens/experiments/experiment-detail-page.js +3 -3
  100. package/dist/screens/experiments/experiment-detail-page.js.map +1 -1
  101. package/dist/screens/experiments/experiment-new-page.js +2 -2
  102. package/dist/screens/experiments/experiment-new-page.js.map +1 -1
  103. package/dist/screens/experiments/experiments-list-page.d.ts.map +1 -1
  104. package/dist/screens/experiments/experiments-list-page.js +3 -2
  105. package/dist/screens/experiments/experiments-list-page.js.map +1 -1
  106. package/dist/screens/experiments/experiments-table.js +1 -1
  107. package/dist/screens/experiments/experiments-table.js.map +1 -1
  108. package/dist/screens/experiments/run-result-labels.d.ts.map +1 -1
  109. package/dist/screens/experiments/run-result-labels.js +3 -4
  110. package/dist/screens/experiments/run-result-labels.js.map +1 -1
  111. package/dist/screens/models/model-form-page.js +2 -2
  112. package/dist/screens/models/model-form-page.js.map +1 -1
  113. package/dist/screens/models/models-list-page.d.ts.map +1 -1
  114. package/dist/screens/models/models-list-page.js +3 -2
  115. package/dist/screens/models/models-list-page.js.map +1 -1
  116. package/dist/screens/optimizations/optimization-detail-page.js +2 -2
  117. package/dist/screens/optimizations/optimization-detail-page.js.map +1 -1
  118. package/dist/screens/optimizations/optimization-new-page.js +2 -2
  119. package/dist/screens/optimizations/optimization-new-page.js.map +1 -1
  120. package/dist/screens/optimizations/optimizations-list-page.d.ts.map +1 -1
  121. package/dist/screens/optimizations/optimizations-list-page.js +3 -2
  122. package/dist/screens/optimizations/optimizations-list-page.js.map +1 -1
  123. package/dist/screens/prompts/prompt-detail-page.d.ts.map +1 -1
  124. package/dist/screens/prompts/prompt-detail-page.js +10 -10
  125. package/dist/screens/prompts/prompt-detail-page.js.map +1 -1
  126. package/dist/screens/prompts/prompt-model.d.ts +5 -2
  127. package/dist/screens/prompts/prompt-model.d.ts.map +1 -1
  128. package/dist/screens/prompts/prompt-model.js +3 -1
  129. package/dist/screens/prompts/prompt-model.js.map +1 -1
  130. package/dist/screens/prompts/prompts-list-page.d.ts.map +1 -1
  131. package/dist/screens/prompts/prompts-list-page.js +46 -21
  132. package/dist/screens/prompts/prompts-list-page.js.map +1 -1
  133. package/dist/screens/quick-start/quick-start-screen.js +2 -2
  134. package/dist/screens/quick-start/quick-start-screen.js.map +1 -1
  135. package/dist/screens/releases/release-input-route-editor.d.ts +39 -0
  136. package/dist/screens/releases/release-input-route-editor.d.ts.map +1 -0
  137. package/dist/screens/releases/release-input-route-editor.js +355 -0
  138. package/dist/screens/releases/release-input-route-editor.js.map +1 -0
  139. package/dist/screens/releases/release-line-detail-page.d.ts +62 -0
  140. package/dist/screens/releases/release-line-detail-page.d.ts.map +1 -1
  141. package/dist/screens/releases/release-line-detail-page.js +1880 -325
  142. package/dist/screens/releases/release-line-detail-page.js.map +1 -1
  143. package/dist/screens/releases/release-line-ui.d.ts.map +1 -1
  144. package/dist/screens/releases/release-line-ui.js +55 -39
  145. package/dist/screens/releases/release-line-ui.js.map +1 -1
  146. package/dist/screens/releases/release-new-model.d.ts.map +1 -1
  147. package/dist/screens/releases/release-new-model.js +1 -6
  148. package/dist/screens/releases/release-new-model.js.map +1 -1
  149. package/dist/screens/releases/release-new-page.d.ts.map +1 -1
  150. package/dist/screens/releases/release-new-page.js +104 -68
  151. package/dist/screens/releases/release-new-page.js.map +1 -1
  152. package/dist/screens/releases/release-topology-canvas.d.ts +11 -2
  153. package/dist/screens/releases/release-topology-canvas.d.ts.map +1 -1
  154. package/dist/screens/releases/release-topology-canvas.js +1015 -174
  155. package/dist/screens/releases/release-topology-canvas.js.map +1 -1
  156. package/dist/screens/releases/releases-list-page.d.ts.map +1 -1
  157. package/dist/screens/releases/releases-list-page.js +82 -33
  158. package/dist/screens/releases/releases-list-page.js.map +1 -1
  159. package/package.json +5 -4
@@ -1,13 +1,17 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useMemo, useState } from 'react';
4
- import { Background, Controls, Handle, MarkerType, Position, ReactFlow, } from '@xyflow/react';
5
- import { Cable, FlaskConical, Plus, RadioTower, Rocket, Save, Split } from 'lucide-react';
6
- import { Button, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, cn, } from '@proofhound/ui';
4
+ import { Link } from '../../components/navigation/link';
5
+ import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Position, ReactFlow, getSmoothStepPath, } from '@xyflow/react';
6
+ import { Cable, Check, ChevronDown, GitCompareArrows, Plus, RadioTower, Rocket, Save, Search, Split, Square, X, } from 'lucide-react';
7
+ import { canaryReleaseFilterRulesSchema, deriveClassificationOptionsFromPromptOutputSchema } from '@proofhound/shared';
8
+ import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn, } from '@proofhound/ui';
7
9
  import { useDateTimeFormatter } from '../../hooks';
8
10
  import { useI18n } from '../../i18n';
9
11
  import { getApiErrorMessage } from '../../lib';
12
+ import { FieldMappingTable, FilterRulesBuilder, canaryInputRouteMappingFromRecord, collectFilterRuleFields, extractInputFieldOptionsFromSnapshot, extractPromptVariablesFromSnapshot, inputRouteMappingRecord, mergeInputFieldOptions, } from './release-input-route-editor';
10
13
  import { ReleasePill, formatPercent } from './release-line-ui';
14
+ const EMPTY_RECORD = {};
11
15
  const TONE_STYLES = {
12
16
  neutral: {
13
17
  bg: 'var(--card)',
@@ -44,19 +48,48 @@ const NODE_ICONS = {
44
48
  upstream: RadioTower,
45
49
  router: Split,
46
50
  production: Rocket,
47
- canary: FlaskConical,
51
+ canary: Split,
52
+ canarySplit: Split,
53
+ canaryDualRun: GitCompareArrows,
48
54
  downstream: Cable,
49
55
  addCanary: Plus,
50
56
  };
57
+ function canaryTopologyIcon(canary) {
58
+ return canary?.trafficMode === 'dual_run' ? 'canaryDualRun' : 'canarySplit';
59
+ }
60
+ function EdgeLaneBadge({ label, items, text }) {
61
+ return (_jsx(TooltipProvider, { delayDuration: 140, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsxs("span", { className: "flex h-6 items-center gap-1 rounded-md border bg-card px-1.5 font-mono text-[10.5px] font-semibold text-muted-foreground shadow-sm", "aria-label": label, title: label, children: [items.map(({ icon: Icon, tone }, index) => (_jsx(Icon, { className: "size-3.5", style: { color: TONE_STYLES[tone].dot }, "aria-hidden": "true" }, `${tone}-${index}`))), text ? _jsx("span", { className: "leading-none", children: text }) : null] }) }), _jsx(TooltipContent, { side: "top", children: label })] }) }));
62
+ }
63
+ function EdgeTextBadge({ label, title = label }) {
64
+ return (_jsx("span", { className: "flex h-5 items-center rounded-md border bg-card px-1.5 font-mono text-[10.5px] font-semibold leading-none text-muted-foreground shadow-sm", "aria-label": title, title: title, children: label }));
65
+ }
51
66
  function ReleaseTopologyNodeCard({ data, selected }) {
52
67
  const token = TONE_STYLES[data.tone];
53
68
  const Icon = NODE_ICONS[data.icon];
54
69
  const borderColor = data.mutedBorder ? 'var(--border)' : token.bd;
55
- return (_jsxs("div", { className: cn('relative h-[116px] w-[236px] cursor-pointer rounded-lg border px-3 py-3 shadow-sm transition', data.tone === 'muted' && 'border-dashed opacity-80', data.action === 'addCanary' && 'hover:border-[var(--status-canary-bd)] hover:opacity-100', selected && 'ring-2 ring-primary/25'), style: { background: token.bg, borderColor }, children: [_jsx(Handle, { type: "target", position: Position.Left, className: "!size-2 !border !bg-card !opacity-0", style: { borderColor: token.bd } }), _jsx(Handle, { type: "source", position: Position.Right, className: "!size-2 !border !bg-card !opacity-0", style: { borderColor: token.bd } }), _jsxs("div", { className: "flex items-start gap-2.5", children: [_jsx("span", { className: "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border", style: { background: 'var(--background)', borderColor: token.bd, color: token.dot }, children: _jsx(Icon, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex min-w-0 items-center justify-between gap-2", children: [_jsx("span", { className: "truncate text-[11.5px] font-medium text-muted-foreground", children: data.label }), data.badges?.[0] ? (_jsx(ReleasePill, { tone: data.tone === 'production' ? 'production' : data.tone === 'canary' ? 'canary' : 'neutral', className: "max-w-[92px] shrink-0 truncate", children: data.badges[0] })) : null] }), _jsx("div", { className: "mt-1 truncate font-mono text-[13px] font-semibold", title: data.title, children: data.title }), data.meta ? (_jsx("div", { className: "mt-1 truncate font-mono text-[11.5px] text-muted-foreground", title: data.meta, children: data.meta })) : null, data.detail ? (_jsx("div", { className: "mt-1.5 truncate text-[11.5px] font-medium", style: { color: token.fg }, title: data.detail, children: data.detail })) : null] })] })] }));
70
+ return (_jsxs("div", { className: cn('relative h-[116px] w-[236px] cursor-pointer rounded-lg border px-3 py-3 shadow-sm transition', data.tone === 'muted' && 'border-dashed opacity-80', data.action === 'addCanary' && 'hover:border-[var(--status-canary-bd)] hover:opacity-100', selected && 'ring-2 ring-primary/25'), style: { background: token.bg, borderColor }, children: [_jsx(Handle, { type: "target", position: Position.Left, className: "!size-2 !border !bg-card !opacity-0", style: { borderColor: token.bd } }), _jsx(Handle, { type: "source", position: Position.Right, className: "!size-2 !border !bg-card !opacity-0", style: { borderColor: token.bd } }), data.compact ? (_jsxs("div", { className: "flex h-full items-center justify-center gap-2.5", children: [_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-md border", style: { background: 'var(--background)', borderColor: token.bd, color: token.dot }, children: _jsx(Icon, { className: "size-4" }) }), _jsx("span", { className: "truncate text-[13px] font-semibold", children: data.label })] })) : (_jsxs("div", { className: "flex items-start gap-2.5", children: [_jsx("span", { className: "mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border", style: { background: 'var(--background)', borderColor: token.bd, color: token.dot }, children: _jsx(Icon, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex min-w-0 items-center justify-between gap-2", children: [_jsx("span", { className: "truncate text-[11.5px] font-medium text-muted-foreground", children: data.label }), data.badges?.[0] ? (_jsx(ReleasePill, { tone: data.tone === 'production' ? 'production' : data.tone === 'canary' ? 'canary' : 'neutral', className: "max-w-[92px] shrink-0 truncate", children: data.badges[0] })) : null] }), _jsx("div", { className: "mt-1 truncate font-mono text-[13px] font-semibold", title: data.title, children: data.title }), data.meta ? (_jsx("div", { className: "mt-1 truncate font-mono text-[11.5px] text-muted-foreground", title: data.meta, children: data.meta })) : null, data.detail ? (_jsx("div", { className: "mt-1.5 truncate text-[11.5px] font-medium", style: { color: token.fg }, title: data.detail, children: data.detail })) : null] })] }))] }));
56
71
  }
57
72
  const nodeTypes = {
58
73
  releaseTopology: ReleaseTopologyNodeCard,
59
74
  };
75
+ function ReleaseTopologyEdgePath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, markerEnd, style, label, interactionWidth, }) {
76
+ const [edgePath, labelX, labelY] = getSmoothStepPath({
77
+ sourceX,
78
+ sourceY,
79
+ sourcePosition,
80
+ targetX,
81
+ targetY,
82
+ targetPosition,
83
+ });
84
+ return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { path: edgePath, markerEnd: markerEnd, style: style, interactionWidth: interactionWidth }), label ? (_jsx(EdgeLabelRenderer, { children: _jsx("div", { className: "nodrag nopan absolute -translate-x-1/2 -translate-y-1/2", style: {
85
+ pointerEvents: 'all',
86
+ zIndex: 20,
87
+ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
88
+ }, children: label }) })) : null] }));
89
+ }
90
+ const edgeTypes = {
91
+ releaseTopology: ReleaseTopologyEdgePath,
92
+ };
60
93
  function createEdge({ id, source, target, label, tone = 'neutral', dashed = false, animated, }) {
61
94
  const color = EDGE_STYLES[tone];
62
95
  return {
@@ -64,7 +97,7 @@ function createEdge({ id, source, target, label, tone = 'neutral', dashed = fals
64
97
  source,
65
98
  target,
66
99
  label,
67
- type: 'smoothstep',
100
+ type: 'releaseTopology',
68
101
  animated: animated ?? (tone === 'production' || tone === 'canary'),
69
102
  markerEnd: { type: MarkerType.ArrowClosed, color },
70
103
  style: {
@@ -72,25 +105,13 @@ function createEdge({ id, source, target, label, tone = 'neutral', dashed = fals
72
105
  strokeWidth: tone === 'muted' ? 1.3 : 2,
73
106
  strokeDasharray: dashed ? '6 5' : undefined,
74
107
  },
75
- labelStyle: {
76
- fill: 'var(--muted-foreground)',
77
- fontSize: 11,
78
- fontFamily: 'var(--font-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
79
- fontWeight: 600,
80
- },
81
- labelBgStyle: {
82
- fill: 'var(--card)',
83
- fillOpacity: 0.92,
84
- },
85
- labelBgPadding: [6, 4],
86
- labelBgBorderRadius: 6,
87
108
  };
88
109
  }
89
110
  function outputNodePosition(index, total) {
90
111
  const compact = total <= 2;
91
112
  const step = compact ? 126 : 108;
92
113
  const startY = 136 - ((total - 1) * step) / 2;
93
- return { x: 1240, y: startY + index * step };
114
+ return { x: 1420, y: startY + index * step };
94
115
  }
95
116
  function clampTrafficRatio(value) {
96
117
  return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
@@ -110,9 +131,12 @@ function getTrafficState(line) {
110
131
  canaryHasTraffic: Boolean(line.canary) && canaryRatio > 0,
111
132
  };
112
133
  }
134
+ function getOutputScopeLabel({ inProduction, inCanary, labels, }) {
135
+ return [inProduction ? labels.productionLane : null, inCanary ? labels.canaryLane : null].filter(Boolean).join(' + ');
136
+ }
113
137
  function buildTopology(line, labels) {
114
138
  const traffic = getTrafficState(line);
115
- const canaryTraffic = line.canary ? formatPercent(traffic.canaryRatio, 0) : labels.noTraffic;
139
+ const canAnimateTraffic = line.status === 'running';
116
140
  const canAddCanary = !line.canary && line.production?.currentEvent?.status === 'running';
117
141
  const nodes = [
118
142
  {
@@ -133,9 +157,9 @@ function buildTopology(line, labels) {
133
157
  data: {
134
158
  icon: 'router',
135
159
  label: labels.inputRoute,
136
- title: `${labels.productionTrafficLabel} ${labels.productionTraffic}`,
137
- meta: `${labels.canaryTrafficLabel} ${canaryTraffic}`,
160
+ title: labels.inputRoute,
138
161
  tone: 'neutral',
162
+ compact: true,
139
163
  },
140
164
  },
141
165
  ];
@@ -144,14 +168,13 @@ function buildTopology(line, labels) {
144
168
  id: 'upstream-router',
145
169
  source: 'upstream',
146
170
  target: 'input-route',
147
- label: labels.ingress,
148
171
  }),
149
172
  ];
150
173
  if (line.production?.currentEvent) {
151
174
  nodes.push({
152
175
  id: 'production',
153
176
  type: 'releaseTopology',
154
- position: { x: 620, y: 62 },
177
+ position: { x: 680, y: 62 },
155
178
  data: {
156
179
  icon: 'production',
157
180
  label: labels.productionNode,
@@ -167,16 +190,16 @@ function buildTopology(line, labels) {
167
190
  id: 'router-production',
168
191
  source: 'input-route',
169
192
  target: 'production',
170
- label: line.canary ? labels.productionTraffic : '100%',
193
+ label: (_jsx(EdgeTextBadge, { label: line.canary ? labels.productionTraffic : '100%', title: `${labels.productionTrafficLabel} ${line.canary ? labels.productionTraffic : '100%'}` })),
171
194
  tone: 'production',
172
- animated: traffic.productionHasTraffic,
195
+ animated: canAnimateTraffic && traffic.productionHasTraffic,
173
196
  }));
174
197
  }
175
198
  else {
176
199
  nodes.push({
177
200
  id: 'production',
178
201
  type: 'releaseTopology',
179
- position: { x: 620, y: 62 },
202
+ position: { x: 680, y: 62 },
180
203
  data: {
181
204
  icon: 'production',
182
205
  label: labels.productionNode,
@@ -189,7 +212,7 @@ function buildTopology(line, labels) {
189
212
  id: 'router-production-empty',
190
213
  source: 'input-route',
191
214
  target: 'production',
192
- label: labels.noTraffic,
215
+ label: (_jsx(EdgeTextBadge, { label: labels.noTraffic, title: `${labels.productionTrafficLabel} ${labels.noTraffic}` })),
193
216
  tone: 'muted',
194
217
  dashed: true,
195
218
  }));
@@ -198,9 +221,9 @@ function buildTopology(line, labels) {
198
221
  nodes.push({
199
222
  id: 'canary',
200
223
  type: 'releaseTopology',
201
- position: { x: 620, y: 210 },
224
+ position: { x: 680, y: 210 },
202
225
  data: {
203
- icon: 'canary',
226
+ icon: canaryTopologyIcon(line.canary),
204
227
  label: labels.canaryNode,
205
228
  title: line.promptName,
206
229
  meta: line.canaryVersionLabel ?? labels.unconfigured,
@@ -214,18 +237,18 @@ function buildTopology(line, labels) {
214
237
  id: 'router-canary',
215
238
  source: 'input-route',
216
239
  target: 'canary',
217
- label: formatPercent(traffic.canaryRatio, 0),
240
+ label: (_jsx(EdgeTextBadge, { label: formatPercent(traffic.canaryRatio, 0), title: `${labels.canaryTrafficLabel} ${formatPercent(traffic.canaryRatio, 0)}` })),
218
241
  tone: 'canary',
219
- animated: traffic.canaryHasTraffic,
242
+ animated: canAnimateTraffic && traffic.canaryHasTraffic,
220
243
  }));
221
244
  }
222
245
  else {
223
246
  nodes.push({
224
247
  id: 'canary',
225
248
  type: 'releaseTopology',
226
- position: { x: 620, y: 210 },
249
+ position: { x: 680, y: 210 },
227
250
  data: {
228
- icon: canAddCanary ? 'addCanary' : 'canary',
251
+ icon: canAddCanary ? 'addCanary' : 'canarySplit',
229
252
  label: labels.canaryNode,
230
253
  title: canAddCanary ? labels.addCanary : labels.noCanary,
231
254
  meta: canAddCanary ? labels.noCanary : undefined,
@@ -239,7 +262,7 @@ function buildTopology(line, labels) {
239
262
  id: 'router-canary-empty',
240
263
  source: 'input-route',
241
264
  target: 'canary',
242
- label: labels.noTraffic,
265
+ label: _jsx(EdgeTextBadge, { label: labels.noTraffic, title: `${labels.canaryTrafficLabel} ${labels.noTraffic}` }),
243
266
  tone: 'muted',
244
267
  dashed: true,
245
268
  }));
@@ -249,32 +272,28 @@ function buildTopology(line, labels) {
249
272
  nodes.push({
250
273
  id: 'output-route',
251
274
  type: 'releaseTopology',
252
- position: { x: 930, y: 136 },
275
+ position: { x: 990, y: 136 },
253
276
  data: {
254
277
  icon: 'router',
255
278
  label: labels.outputRoute,
256
- title: labels.outputRouteTitle,
257
- meta: labels.outputMapping,
258
- detail: line.canary?.outputMapping.length ? `${line.canary.outputMapping.length}` : labels.outputMappingEmpty,
279
+ title: labels.outputRoute,
259
280
  tone: 'neutral',
260
- badges: [labels.outputDirection],
281
+ compact: true,
261
282
  },
262
283
  });
263
284
  edges.push(createEdge({
264
285
  id: 'production-output-route',
265
286
  source: 'production',
266
287
  target: 'output-route',
267
- label: 'production',
268
288
  tone: line.production?.currentEvent ? 'production' : 'muted',
269
- animated: traffic.productionHasTraffic,
289
+ animated: canAnimateTraffic && traffic.productionHasTraffic,
270
290
  dashed: !line.production?.currentEvent,
271
291
  }), createEdge({
272
292
  id: 'canary-output-route',
273
293
  source: 'canary',
274
294
  target: 'output-route',
275
- label: 'gray',
276
295
  tone: line.canary ? 'canary' : 'muted',
277
- animated: traffic.canaryHasTraffic,
296
+ animated: canAnimateTraffic && traffic.canaryHasTraffic,
278
297
  dashed: !line.canary,
279
298
  }));
280
299
  const outputs = line.outputConnectors.length > 0
@@ -285,6 +304,12 @@ function buildTopology(line, labels) {
285
304
  const isEmpty = connector.id === 'empty-output';
286
305
  const inProduction = productionOutputIds.has(connector.id);
287
306
  const inCanary = canaryOutputIds.has(connector.id);
307
+ const outputScope = getOutputScopeLabel({ inProduction, inCanary, labels });
308
+ const CanaryScopeIcon = line.canary?.trafficMode === 'dual_run' ? GitCompareArrows : Split;
309
+ const outputScopeItems = [
310
+ ...(inProduction ? [{ icon: Rocket, tone: 'production' }] : []),
311
+ ...(inCanary ? [{ icon: CanaryScopeIcon, tone: 'canary' }] : []),
312
+ ];
288
313
  nodes.push({
289
314
  id,
290
315
  type: 'releaseTopology',
@@ -294,9 +319,7 @@ function buildTopology(line, labels) {
294
319
  label: labels.downstream,
295
320
  title: connector.name,
296
321
  meta: connector.type,
297
- detail: isEmpty
298
- ? labels.noDownstreamDetail
299
- : [inProduction ? 'production' : null, inCanary ? 'gray' : null].filter(Boolean).join(' + '),
322
+ detail: isEmpty ? labels.noDownstreamDetail : outputScope,
300
323
  tone: isEmpty ? 'muted' : 'neutral',
301
324
  badges: [connector.type],
302
325
  },
@@ -315,9 +338,10 @@ function buildTopology(line, labels) {
315
338
  id: `output-route-${id}`,
316
339
  source: 'output-route',
317
340
  target: id,
318
- label: [inProduction ? 'production' : null, inCanary ? 'gray' : null].filter(Boolean).join(' + ') || undefined,
319
- tone: inCanary ? 'canary' : inProduction ? 'production' : 'neutral',
320
- animated: (inProduction && traffic.productionHasTraffic) || (inCanary && traffic.canaryHasTraffic),
341
+ label: outputScope ? _jsx(EdgeLaneBadge, { label: outputScope, items: outputScopeItems }) : undefined,
342
+ tone: 'neutral',
343
+ animated: canAnimateTraffic &&
344
+ ((inProduction && traffic.productionHasTraffic) || (inCanary && traffic.canaryHasTraffic)),
321
345
  }));
322
346
  });
323
347
  return { nodes, edges };
@@ -332,6 +356,7 @@ function useTopologyLabels(line) {
332
356
  model: t('releases.detail.field.model'),
333
357
  externalId: t('releases.detail.field.externalId'),
334
358
  startedAt: t('releases.detail.field.startedAt'),
359
+ createdAt: t('common.createdAt'),
335
360
  status: t('releases.detail.field.status'),
336
361
  trafficRatio: t('releases.detail.field.trafficRatio'),
337
362
  updatedAt: t('releases.detail.field.updatedAt'),
@@ -343,11 +368,18 @@ function useTopologyLabels(line) {
343
368
  canaryBadge: t('releases.detail.topology.badge.canary'),
344
369
  productionTrafficLabel: t('releases.detail.topology.traffic.production'),
345
370
  canaryTrafficLabel: t('releases.detail.topology.traffic.canary'),
371
+ productionLane: t('releases.detail.topology.lane.production'),
372
+ canaryLane: t('releases.detail.topology.lane.canary'),
346
373
  router: t('releases.detail.topology.router'),
347
374
  inputRoute: t('releases.detail.topology.inputRoute'),
348
375
  inputRouteMeta: t('releases.detail.topology.inputRouteMeta'),
349
376
  outputRoute: t('releases.detail.topology.outputRoute'),
350
377
  outputRouteTitle: t('releases.detail.topology.outputRouteTitle'),
378
+ inputRouteSaveFailed: t('releases.detail.topology.inputRoute.saveFailed'),
379
+ inputRouteInvalid: t('releases.detail.topology.inputRoute.invalid'),
380
+ inputRouteNoLane: t('releases.detail.topology.inputRoute.noLane'),
381
+ inputRouteEditFilter: t('releases.detail.topology.inputRoute.editFilter'),
382
+ inputRouteHideFilter: t('releases.detail.topology.inputRoute.hideFilter'),
351
383
  ingress: t('releases.detail.topology.ingress'),
352
384
  routeMeta: t('releases.detail.topology.routeMeta'),
353
385
  latestEvent: t('releases.detail.topology.latestEvent'),
@@ -365,7 +397,6 @@ function useTopologyLabels(line) {
365
397
  offline: t('releases.traffic.offline'),
366
398
  readyForCandidate: t('releases.detail.topology.readyForCandidate'),
367
399
  inspector: t('releases.detail.topology.inspector'),
368
- clickHint: t('releases.detail.topology.clickHint'),
369
400
  connectorId: t('releases.detail.topology.field.connectorId'),
370
401
  connectorName: t('releases.detail.topology.field.connectorName'),
371
402
  connectorType: t('releases.detail.topology.field.connectorType'),
@@ -380,81 +411,336 @@ function useTopologyLabels(line) {
380
411
  tpmLimit: t('releases.detail.topology.field.tpmLimit'),
381
412
  concurrency: t('releases.detail.topology.field.concurrency'),
382
413
  temperature: t('releases.detail.topology.field.temperature'),
383
- recordMode: t('releases.detail.topology.field.recordMode'),
414
+ termination: t('releases.detail.topology.field.termination'),
384
415
  trafficMode: t('releases.detail.topology.field.trafficMode'),
385
416
  outputScope: t('releases.detail.topology.field.outputScope'),
386
417
  productionScope: t('releases.detail.topology.scope.production'),
387
418
  canaryScope: t('releases.detail.topology.scope.canary'),
388
- routeStatus: t('releases.detail.topology.field.routeStatus'),
389
419
  fieldMapping: t('releases.detail.topology.field.fieldMapping'),
390
- fieldMappingEmpty: t('releases.detail.config.mappingEmpty'),
391
420
  filterRules: t('releases.detail.topology.field.filterRules'),
392
421
  filterEmpty: t('releases.detail.topology.filterEmpty'),
422
+ inputFields: t('releases.detail.topology.field.inputFields'),
423
+ inputFieldsEmpty: t('releases.detail.topology.inputFields.empty'),
393
424
  outputMapping: t('releases.detail.topology.field.outputMapping'),
394
425
  outputMappingEmpty: t('releases.detail.topology.outputMappingEmpty'),
426
+ outputRouteSaveFailed: t('releases.detail.topology.outputRoute.saveFailed'),
427
+ outputRouteNoLane: t('releases.detail.topology.outputRoute.noLane'),
428
+ outputRouteNoConnector: t('releases.detail.topology.outputRoute.noConnector'),
429
+ outputRouteEdit: t('releases.detail.topology.outputRoute.edit'),
430
+ outputRouteDialogTitle: t('releases.detail.topology.outputRoute.dialogTitle'),
431
+ outputRouteDialogDescription: t('releases.detail.topology.outputRoute.dialogDescription'),
432
+ outputRouteNewConnector: t('releases.detail.topology.outputRoute.newConnector'),
433
+ outputRouteConnectorFilter: t('releases.detail.topology.outputRoute.connectorFilter'),
434
+ outputRouteConnectorSelect: t('releases.detail.topology.outputRoute.connectorSelect'),
435
+ outputRouteNoAddableConnector: t('releases.detail.topology.outputRoute.noAddableConnector'),
436
+ outputRouteAvailableConnectors: t('releases.detail.topology.outputRoute.availableConnectors'),
437
+ outputRouteSelectedConnectors: t('releases.detail.topology.outputRoute.selectedConnectors'),
438
+ outputRouteNoSelectedConnector: t('releases.detail.topology.outputRoute.noSelectedConnector'),
439
+ outputRouteAddConnector: t('releases.detail.topology.outputRoute.addConnector'),
440
+ outputRouteRemoveConnector: t('releases.detail.topology.outputRoute.removeConnector'),
441
+ outputRoutePassThrough: t('releases.detail.topology.outputRoute.passThrough'),
442
+ outputRouteConnectorHelp: t('releases.detail.topology.outputRoute.connectorHelp'),
443
+ outputRouteMappingHelp: t('releases.detail.topology.outputRoute.mappingHelp'),
444
+ outputRouteSource: t('releases.detail.topology.outputRoute.source'),
445
+ outputRouteTarget: t('releases.detail.topology.outputRoute.target'),
446
+ outputRouteSourceDecisionOutput: t('releases.detail.topology.outputRoute.sourceDecisionOutput'),
447
+ outputRouteSourceParsedOutput: t('releases.detail.topology.outputRoute.sourceParsedOutput'),
448
+ outputRouteSourceRawResponse: t('releases.detail.topology.outputRoute.sourceRawResponse'),
449
+ outputRouteSourceRunResultId: t('releases.detail.topology.outputRoute.sourceRunResultId'),
450
+ outputRouteSourceModelOutput: t('releases.detail.topology.outputRoute.sourceModelOutput'),
451
+ outputRouteSourceLegacy: t('releases.detail.topology.outputRoute.sourceLegacy'),
452
+ outputRouteProductionConnectors: t('releases.detail.topology.outputRoute.productionConnectors'),
453
+ outputRouteCanaryConnectors: t('releases.detail.topology.outputRoute.canaryConnectors'),
454
+ outputRouteProductionMapping: t('releases.detail.topology.outputRoute.productionMapping'),
455
+ outputRouteCanaryMapping: t('releases.detail.topology.outputRoute.canaryMapping'),
456
+ outputRouteSummaryConnectors: t('releases.detail.topology.outputRoute.summaryConnectors'),
457
+ outputRouteSummaryMapping: t('releases.detail.topology.outputRoute.summaryMapping'),
458
+ outputRouteSave: t('releases.detail.topology.outputRoute.saveRoute'),
459
+ outputRouteSourceSearch: t('releases.detail.topology.outputRoute.sourceSearch'),
460
+ outputRouteSourceEmpty: t('releases.detail.topology.outputRoute.sourceEmpty'),
461
+ outputRouteSourcePlaceholder: t('releases.detail.topology.outputRoute.sourcePlaceholder'),
462
+ outputRouteSourceGroupUpstream: t('releases.detail.topology.outputRoute.sourceGroup.upstream'),
463
+ outputRouteSourceGroupModel: t('releases.detail.topology.outputRoute.sourceGroup.model'),
464
+ outputRouteSourceGroupMeta: t('releases.detail.topology.outputRoute.sourceGroup.meta'),
465
+ outputRouteNewBadge: t('releases.detail.topology.outputRoute.newBadge'),
466
+ outputRouteConnectorPickerTitle: t('releases.detail.topology.outputRoute.connectorPickerTitle'),
467
+ outputRouteNoMoreConnectors: t('releases.detail.topology.outputRoute.noMoreConnectors'),
468
+ outputRouteLaneReadonly: t('releases.detail.topology.outputRoute.laneReadonly'),
469
+ outputRouteConnectorCount: (count) => formatTemplate(t('releases.detail.topology.outputRoute.connectorCount'), { count }),
470
+ outputRouteFooterSummary: (production, canary, mappings) => formatTemplate(t('releases.detail.topology.outputRoute.footerSummary'), { production, canary, mappings }),
471
+ addMapping: t('releases.detail.topology.outputRoute.addMapping'),
472
+ removeMapping: t('releases.detail.topology.outputRoute.removeMapping'),
395
473
  adjustTraffic: t('releases.detail.action.adjustTraffic'),
396
474
  trafficBox: t('releases.detail.topology.trafficBox'),
397
475
  noCanaryToAdjust: t('releases.detail.topology.noCanaryToAdjust'),
398
476
  trafficInvalid: t('releases.detail.trafficDialog.invalid'),
399
477
  trafficUpdateFailed: t('releases.detail.trafficDialog.updateFailed'),
478
+ laneIdentityTitle: t('releases.detail.topology.identity.title'),
400
479
  runConfigTitle: t('releases.detail.topology.runConfig.title'),
401
480
  runConfigInvalid: t('releases.detail.topology.runConfig.invalid'),
402
481
  runConfigUpdateFailed: t('releases.detail.topology.runConfig.updateFailed'),
403
482
  runConfigModelUnavailable: t('releases.detail.topology.runConfig.modelUnavailable'),
404
483
  modelLimit: (limit) => t('releases.detail.topology.runConfig.modelLimit').replace('{limit}', limit),
405
484
  unlimited: t('releases.detail.topology.runConfig.unlimited'),
485
+ terminationManual: t('canaryReleases.new.field.termination.manual'),
486
+ terminationByCount: t('canaryReleases.new.field.termination.byCount'),
487
+ terminationByTime: t('canaryReleases.new.field.termination.byTime'),
488
+ recordModeTitle: t('releases.detail.topology.recordMode.title'),
489
+ recordCategories: t('releases.detail.topology.recordCategories'),
490
+ recordCategoriesEmpty: t('releases.detail.topology.recordCategories.empty'),
491
+ recordCategoriesSelected: (count) => formatTemplate(t('releases.detail.topology.recordCategories.selected'), { count }),
492
+ recordCategoriesSelectAll: t('releases.detail.topology.recordCategories.selectAll'),
493
+ recordCategoriesClear: t('releases.detail.topology.recordCategories.clear'),
494
+ canaryActionFailed: t('releases.detail.topology.canaryActionFailed'),
495
+ promoteCanary: t('releases.detail.action.promoteCanary'),
496
+ stopCanary: t('releases.detail.action.stopCanary'),
497
+ replaceCanary: t('releases.detail.action.replaceCanary'),
406
498
  loading: t('common.loading'),
407
499
  trafficPercentInput: t('releases.detail.trafficDialog.percentInput'),
408
500
  trafficPercentAriaLabel: t('releases.detail.trafficDialog.percentAriaLabel'),
501
+ cancel: t('common.cancel'),
409
502
  save: t('common.save'),
410
503
  savePending: t('common.savePending'),
411
504
  canaryMode: (mode) => mode === 'dual_run' ? t('releases.detail.topology.mode.dualRun') : t('releases.detail.topology.mode.split'),
505
+ filterOp: (op) => t(`canaryReleases.new.filter.op.${op}`),
412
506
  };
413
507
  }
508
+ function formatTemplate(template, values) {
509
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
510
+ const value = values[key];
511
+ return value === undefined ? `{${key}}` : String(value);
512
+ });
513
+ }
414
514
  function toDisplayValue(value) {
415
515
  if (value === null || value === undefined || value === '')
416
516
  return '—';
417
517
  return String(value);
418
518
  }
419
- function stringifyConfig(value, emptyLabel) {
420
- if (value === null || value === undefined)
421
- return emptyLabel;
422
- if (typeof value === 'string')
423
- return value.trim() || emptyLabel;
424
- if (Array.isArray(value) && value.length === 0)
425
- return emptyLabel;
426
- if (typeof value === 'object' && Object.keys(value).length === 0)
427
- return emptyLabel;
428
- return JSON.stringify(value, null, 2);
429
- }
430
- function getVariableMappingBody(line, emptyLabel) {
431
- if (line.canary) {
432
- if (line.canary.variableMapping.length === 0)
433
- return emptyLabel;
434
- return line.canary.variableMapping
435
- .map((item) => {
436
- const required = item.required ? ' *' : '';
437
- const defaultValue = item.defaultValue === undefined ? '' : ` = ${stringifyConfig(item.defaultValue, '')}`;
438
- return `${item.source} -> ${item.target}${required}${defaultValue}`;
439
- })
440
- .join('\n');
519
+ function positiveIntegerValue(value) {
520
+ return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
521
+ }
522
+ function formatCanaryStopConditions(config, labels) {
523
+ const stopConditions = readCanaryStopConditions(readRecord(config)?.['stopConditions']);
524
+ if (!stopConditions)
525
+ return labels.terminationManual;
526
+ const parts = [];
527
+ if (stopConditions.maxSamples !== null)
528
+ parts.push(`${labels.terminationByCount} · ${stopConditions.maxSamples}`);
529
+ if (stopConditions.maxDurationSeconds !== null) {
530
+ parts.push(`${labels.terminationByTime} · ${stopConditions.maxDurationSeconds}s`);
441
531
  }
442
- const productionMapping = line.production?.currentEvent?.variableMapping ?? {};
443
- const entries = Object.entries(productionMapping);
444
- if (entries.length === 0)
445
- return emptyLabel;
446
- return entries.map(([target, source]) => `${source} -> ${target}${target === 'id' ? ' *' : ''}`).join('\n');
532
+ return parts.length > 0 ? parts.join(' / ') : labels.terminationManual;
447
533
  }
448
- function getFilterRulesBody(line, labels) {
449
- const filterRules = line.canary?.filterRules ?? line.production?.currentEvent?.filterRules ?? null;
450
- return stringifyConfig(filterRules, labels.filterEmpty);
534
+ function readCanaryStopConditions(value) {
535
+ const record = readRecord(value);
536
+ if (!record)
537
+ return null;
538
+ const maxSamples = positiveIntegerValue(record['maxSamples']);
539
+ const maxDurationSeconds = positiveIntegerValue(record['maxDurationSeconds']);
540
+ if (maxSamples === null && maxDurationSeconds === null)
541
+ return null;
542
+ return { maxDurationSeconds, maxSamples };
543
+ }
544
+ function normalizeOutputMapping(value) {
545
+ if (!Array.isArray(value))
546
+ return [];
547
+ return value
548
+ .filter((item) => {
549
+ return (Boolean(item) &&
550
+ typeof item === 'object' &&
551
+ 'source' in item &&
552
+ 'target' in item &&
553
+ typeof item.source === 'string' &&
554
+ typeof item.target === 'string');
555
+ })
556
+ .map((item) => ({ source: item.source, target: item.target }));
451
557
  }
452
- function getOutputMappingBody(line, labels) {
453
- const canaryMapping = line.canary?.outputMapping ?? [];
454
- if (canaryMapping.length > 0) {
455
- return canaryMapping.map((item) => `${item.source} -> ${item.target}`).join('\n');
558
+ function normalizeConnectorOutputRoutes(value, connectorIds) {
559
+ const selectedIds = new Set(connectorIds);
560
+ const routes = new Map();
561
+ if (Array.isArray(value)) {
562
+ for (const item of value) {
563
+ if (!item || typeof item !== 'object' || Array.isArray(item))
564
+ continue;
565
+ const record = item;
566
+ const connectorId = record['connectorId'];
567
+ if (typeof connectorId !== 'string' || !selectedIds.has(connectorId))
568
+ continue;
569
+ routes.set(connectorId, normalizeOutputMapping(record['outputMapping']));
570
+ }
456
571
  }
457
- return labels.outputMappingEmpty;
572
+ if (routes.size > 0) {
573
+ return connectorIds.map((connectorId) => ({
574
+ connectorId,
575
+ outputMapping: routes.get(connectorId) ?? [],
576
+ }));
577
+ }
578
+ const legacyMapping = normalizeOutputMapping(value);
579
+ return connectorIds.map((connectorId) => ({ connectorId, outputMapping: legacyMapping }));
580
+ }
581
+ function countOutputMappingRows(value) {
582
+ if (!Array.isArray(value))
583
+ return 0;
584
+ const connectorRouteCount = value.reduce((total, item) => {
585
+ if (!item || typeof item !== 'object' || Array.isArray(item))
586
+ return total;
587
+ const record = item;
588
+ if (typeof record['connectorId'] !== 'string')
589
+ return total;
590
+ return total + normalizeOutputMapping(record['outputMapping']).length;
591
+ }, 0);
592
+ return connectorRouteCount > 0 ? connectorRouteCount : normalizeOutputMapping(value).length;
593
+ }
594
+ function serializeOutputRoutes(value) {
595
+ return JSON.stringify(value
596
+ .map((route) => ({
597
+ connectorId: route.connectorId,
598
+ outputMapping: cleanOutputMapping(route.outputMapping),
599
+ }))
600
+ .sort((left, right) => left.connectorId.localeCompare(right.connectorId)));
601
+ }
602
+ function cleanOutputMapping(value) {
603
+ return normalizeOutputMapping(value)
604
+ .map((item) => ({ source: item.source.trim(), target: item.target.trim() }))
605
+ .filter((item) => item.source.length > 0 && item.target.length > 0);
606
+ }
607
+ function cleanOutputRoutes(value) {
608
+ return value.map((route) => ({
609
+ connectorId: route.connectorId,
610
+ outputMapping: cleanOutputMapping(route.outputMapping),
611
+ }));
612
+ }
613
+ function getOutputSourceGroups(snapshot, upstreamFields, labels) {
614
+ const upstreamOptions = dedupeOutputSourceOptions(upstreamFields.map((field) => ({
615
+ value: field.key,
616
+ label: field.key,
617
+ description: field.description || field.type,
618
+ })));
619
+ const modelOptions = [
620
+ {
621
+ value: 'decision_output',
622
+ label: labels.outputRouteSourceDecisionOutput,
623
+ description: 'decision_output',
624
+ },
625
+ {
626
+ value: 'parsed_output',
627
+ label: labels.outputRouteSourceParsedOutput,
628
+ description: 'parsed_output',
629
+ },
630
+ {
631
+ value: 'raw_response',
632
+ label: labels.outputRouteSourceRawResponse,
633
+ description: 'raw_response',
634
+ },
635
+ ];
636
+ const metaOptions = [
637
+ {
638
+ value: 'status',
639
+ label: labels.status,
640
+ description: 'status',
641
+ },
642
+ {
643
+ value: 'external_id',
644
+ label: labels.externalId,
645
+ description: 'external_id',
646
+ },
647
+ {
648
+ value: 'run_result_id',
649
+ label: labels.outputRouteSourceRunResultId,
650
+ description: 'run_result_id',
651
+ },
652
+ {
653
+ value: 'metrics.latency_ms',
654
+ label: labels.outputRouteSourceModelOutput,
655
+ description: 'metrics.latency_ms',
656
+ },
657
+ ];
658
+ const outputSchema = readRecord(readRecord(snapshot)?.['outputSchema']);
659
+ const fields = Array.isArray(outputSchema?.['fields']) ? outputSchema['fields'] : [];
660
+ const schemaOptions = fields
661
+ .map((field) => {
662
+ const record = readRecord(field);
663
+ const key = typeof record?.['key'] === 'string' ? record['key'].trim() : '';
664
+ if (!key)
665
+ return null;
666
+ const value = typeof record?.['value'] === 'string' ? record['value'].trim() : '';
667
+ return {
668
+ value: key,
669
+ label: key,
670
+ description: value || labels.outputRouteSourceModelOutput,
671
+ };
672
+ })
673
+ .filter((option) => option !== null);
674
+ const groups = [
675
+ {
676
+ key: 'upstream',
677
+ label: labels.outputRouteSourceGroupUpstream,
678
+ badge: labels.outputRouteNewBadge,
679
+ options: upstreamOptions,
680
+ },
681
+ {
682
+ key: 'model',
683
+ label: labels.outputRouteSourceGroupModel,
684
+ options: dedupeOutputSourceOptions([...schemaOptions, ...modelOptions]),
685
+ },
686
+ {
687
+ key: 'meta',
688
+ label: labels.outputRouteSourceGroupMeta,
689
+ options: metaOptions,
690
+ },
691
+ ];
692
+ return groups.filter((group) => group.options.length > 0);
693
+ }
694
+ function dedupeOutputSourceOptions(options) {
695
+ return Array.from(new Map(options.map((option) => [option.value, option])).values());
696
+ }
697
+ function readRecord(value) {
698
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
699
+ }
700
+ function getOutputRouteConnectorItems(connectorIds, connectors) {
701
+ const connectorById = new Map(connectors.map((connector) => [connector.id, connector]));
702
+ return connectorIds.map((connectorId) => ({
703
+ id: connectorId,
704
+ name: connectorById.get(connectorId)?.name ?? connectorId,
705
+ }));
706
+ }
707
+ function OutputRouteConnectorList({ connectorIds, connectors, emptyLabel, }) {
708
+ const items = getOutputRouteConnectorItems(connectorIds, connectors);
709
+ if (items.length === 0)
710
+ return _jsx("span", { className: "text-muted-foreground", children: emptyLabel });
711
+ return (_jsx("div", { className: "space-y-1", children: items.map((item, index) => (_jsx("div", { className: "truncate", title: item.name, children: item.name }, `${item.id}:${index}`))) }));
712
+ }
713
+ function getOutputRouteMappingSummary(outputMapping, labels) {
714
+ const count = countOutputMappingRows(outputMapping);
715
+ return count > 0 ? String(count) : labels.outputRoutePassThrough;
716
+ }
717
+ function OutputRouteSummaryCards({ line, outputConnectors, labels, }) {
718
+ const productionEvent = line.production?.currentEvent ?? null;
719
+ const productionConnectorIds = productionEvent?.outputConnectorIds ?? [];
720
+ const canaryConnectorIds = line.canary?.outputConnectorIds ?? [];
721
+ return (_jsxs("div", { className: "grid gap-3", children: [_jsx(OutputRouteSummaryCard, { title: labels.production, tone: productionEvent ? 'production' : 'muted', rows: [
722
+ {
723
+ label: labels.outputRouteSummaryConnectors,
724
+ valueNode: (_jsx(OutputRouteConnectorList, { connectorIds: productionConnectorIds, connectors: outputConnectors, emptyLabel: labels.noDownstream })),
725
+ },
726
+ {
727
+ label: labels.outputRouteSummaryMapping,
728
+ value: getOutputRouteMappingSummary(line.productionOutputMapping, labels),
729
+ },
730
+ ] }), _jsx(OutputRouteSummaryCard, { title: labels.canary, tone: line.canary ? 'canary' : 'muted', rows: [
731
+ {
732
+ label: labels.outputRouteSummaryConnectors,
733
+ valueNode: line.canary ? (_jsx(OutputRouteConnectorList, { connectorIds: canaryConnectorIds, connectors: outputConnectors, emptyLabel: labels.noDownstream })) : (_jsx("span", { className: "text-muted-foreground", children: labels.noCanary })),
734
+ },
735
+ {
736
+ label: labels.outputRouteSummaryMapping,
737
+ value: line.canary ? getOutputRouteMappingSummary(line.canaryOutputMapping, labels) : labels.noCanary,
738
+ },
739
+ ] })] }));
740
+ }
741
+ function OutputRouteSummaryCard({ title, tone, rows, }) {
742
+ const token = TONE_STYLES[tone];
743
+ return (_jsxs("section", { className: "rounded-lg border bg-card p-3", style: { borderColor: token.bd }, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "size-2 rounded-full", style: { background: token.dot } }), _jsx("span", { className: "text-[12px] font-semibold", children: title })] }), _jsx("div", { className: "mt-3 space-y-2", children: rows.map((row) => (_jsx(InspectorRowView, { row: { ...row, mono: false } }, `${title}:${row.label}`))) })] }));
458
744
  }
459
745
  function trafficPercentFromText(value, max = 100) {
460
746
  const parsed = Number(value.trim());
@@ -470,8 +756,417 @@ function getProductionTrafficPercent(canaryPercent) {
470
756
  function isAdjustableCanary(canary) {
471
757
  return canary?.status === 'pending' || canary?.status === 'running' || canary?.status === 'stopped';
472
758
  }
759
+ function normalizeInputRouteFilterRules(value) {
760
+ const parsed = canaryReleaseFilterRulesSchema.safeParse(value ?? null);
761
+ return parsed.success ? parsed.data : null;
762
+ }
763
+ function cleanInputRouteMappingRecord(mapping, externalIdField) {
764
+ const result = {};
765
+ for (const [target, source] of Object.entries(mapping)) {
766
+ const cleanTarget = target.trim();
767
+ const cleanSource = source.trim();
768
+ if (!cleanTarget || !cleanSource || cleanTarget === 'id')
769
+ continue;
770
+ result[cleanTarget] = cleanSource;
771
+ }
772
+ if (externalIdField.trim())
773
+ result.id = externalIdField.trim();
774
+ return result;
775
+ }
776
+ function buildInputRouteUpdate(laneType, mapping, filterRules, externalIdField, promptVariables) {
777
+ const normalizedExternalIdField = externalIdField.trim();
778
+ if (!normalizedExternalIdField)
779
+ return null;
780
+ if (!canaryReleaseFilterRulesSchema.safeParse(filterRules).success)
781
+ return null;
782
+ if (promptVariables.some((variable) => !mapping[variable.name]?.trim()))
783
+ return null;
784
+ if (laneType === 'production') {
785
+ return {
786
+ laneType,
787
+ variableMapping: cleanInputRouteMappingRecord(mapping, normalizedExternalIdField),
788
+ filterRules,
789
+ externalIdField: normalizedExternalIdField,
790
+ };
791
+ }
792
+ return {
793
+ laneType,
794
+ variableMapping: canaryInputRouteMappingFromRecord(mapping, promptVariables, normalizedExternalIdField),
795
+ filterRules,
796
+ externalIdField: normalizedExternalIdField,
797
+ };
798
+ }
799
+ function inputRouteSignature(input) {
800
+ return input ? JSON.stringify(input) : null;
801
+ }
802
+ function getInputRouteLane(line, labels, laneType) {
803
+ if (laneType === 'canary') {
804
+ const canary = line.canary;
805
+ if (!canary)
806
+ return null;
807
+ return {
808
+ laneType: 'canary',
809
+ title: labels.canary,
810
+ tone: 'canary',
811
+ canEdit: isAdjustableCanary(canary),
812
+ eventId: canary.id,
813
+ updatedAt: canary.updatedAt,
814
+ variableMapping: canary.variableMapping,
815
+ filterRules: canary.filterRules,
816
+ externalIdField: canary.externalIdField,
817
+ promptVersionSnapshot: line.canaryPromptVersionSnapshot,
818
+ inputConnectorSnapshot: line.inputConnectorSnapshot,
819
+ };
820
+ }
821
+ const event = line.production?.currentEvent ?? null;
822
+ if (!event)
823
+ return null;
824
+ return {
825
+ laneType: 'production',
826
+ title: labels.production,
827
+ tone: 'production',
828
+ canEdit: event.status === 'running',
829
+ eventId: event.id,
830
+ updatedAt: event.updatedAt,
831
+ variableMapping: event.variableMapping,
832
+ filterRules: event.filterRules,
833
+ externalIdField: event.externalIdField,
834
+ promptVersionSnapshot: event.promptVersionSnapshot,
835
+ inputConnectorSnapshot: line.inputConnectorSnapshot,
836
+ };
837
+ }
838
+ function formatFilterValue(value) {
839
+ if (value === undefined || value === null)
840
+ return '';
841
+ if (typeof value === 'string')
842
+ return value;
843
+ if (typeof value === 'number' || typeof value === 'boolean')
844
+ return String(value);
845
+ return JSON.stringify(value);
846
+ }
847
+ function summarizeFilterRules(value, labels, depth = 0) {
848
+ if (!value)
849
+ return labels.filterEmpty;
850
+ if (value.type === 'atom') {
851
+ const op = labels.filterOp(value.op);
852
+ if (value.op === 'exists')
853
+ return `${value.field} ${op}`;
854
+ const displayValue = formatFilterValue(value.value);
855
+ return displayValue ? `${value.field} ${op} ${displayValue}` : `${value.field} ${op}`;
856
+ }
857
+ if (value.type === 'not') {
858
+ return `NOT (${summarizeFilterRules(value.child, labels, depth + 1)})`;
859
+ }
860
+ const joiner = value.type.toUpperCase();
861
+ const summary = value.children.map((child) => summarizeFilterRules(child, labels, depth + 1)).join(` ${joiner} `);
862
+ return depth > 0 && value.children.length > 1 ? `(${summary})` : summary;
863
+ }
864
+ function SimpleFilterRulesView({ value, labels, }) {
865
+ return (_jsx("div", { className: "rounded-md border bg-muted/35 px-3 py-2 font-mono text-[11.5px] leading-5 text-muted-foreground", children: summarizeFilterRules(value, labels) }));
866
+ }
867
+ function collectLaneInputRouteKeys(lane) {
868
+ if (!lane)
869
+ return [];
870
+ return [
871
+ lane.externalIdField ?? '',
872
+ ...Object.values(inputRouteMappingRecord(lane.variableMapping)),
873
+ ...collectFilterRuleFields(normalizeInputRouteFilterRules(lane.filterRules)),
874
+ ];
875
+ }
876
+ function getLineInputFieldOptions(line, labels) {
877
+ return mergeInputFieldOptions(extractInputFieldOptionsFromSnapshot(line.inputConnectorSnapshot), [
878
+ ...collectLaneInputRouteKeys(getInputRouteLane(line, labels, 'production')),
879
+ ...collectLaneInputRouteKeys(getInputRouteLane(line, labels, 'canary')),
880
+ ]);
881
+ }
882
+ function UpstreamInputFields({ line, labels, }) {
883
+ const fields = getLineInputFieldOptions(line, labels);
884
+ return (_jsxs("section", { className: "mt-4 rounded-lg border bg-card", children: [_jsx("div", { className: "border-b px-3 py-2 text-[12px] font-semibold", children: labels.inputFields }), fields.length === 0 ? (_jsx("div", { className: "px-3 py-3 text-[12px] text-muted-foreground", children: labels.inputFieldsEmpty })) : (_jsx("div", { className: "max-h-56 overflow-auto p-2", children: _jsx("div", { className: "space-y-1.5", children: fields.map((field) => (_jsxs("div", { className: "rounded-md border bg-background px-2 py-1.5", children: [_jsx("div", { className: "break-all font-mono text-[11.5px] font-semibold", children: field.key }), _jsxs("div", { className: "mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10.5px] text-muted-foreground", children: [_jsx("span", { children: field.type }), field.description ? _jsx("span", { className: "break-words", children: field.description }) : null] })] }, field.key))) }) }))] }));
885
+ }
886
+ function InputRouteLaneEditor({ lane, labels, pending, onUpdateInputRoute, }) {
887
+ const initialMapping = inputRouteMappingRecord(lane.variableMapping);
888
+ const initialExternalIdField = lane.externalIdField ?? initialMapping.id ?? '';
889
+ const initialFilterRules = normalizeInputRouteFilterRules(lane.filterRules);
890
+ const [mapping, setMapping] = useState(initialMapping);
891
+ const [externalIdField, setExternalIdField] = useState(initialExternalIdField);
892
+ const [filterRules, setFilterRules] = useState(initialFilterRules);
893
+ const [savedSignature, setSavedSignature] = useState(() => {
894
+ const promptVariables = extractPromptVariablesFromSnapshot(lane.promptVersionSnapshot, initialMapping);
895
+ return inputRouteSignature(buildInputRouteUpdate(lane.laneType, initialMapping, initialFilterRules, initialExternalIdField, promptVariables));
896
+ });
897
+ const [filterEditorOpen, setFilterEditorOpen] = useState(false);
898
+ const [error, setError] = useState(null);
899
+ const token = TONE_STYLES[lane.tone];
900
+ const promptVariables = useMemo(() => extractPromptVariablesFromSnapshot(lane.promptVersionSnapshot, mapping), [lane.promptVersionSnapshot, mapping]);
901
+ const fieldOptions = useMemo(() => mergeInputFieldOptions(extractInputFieldOptionsFromSnapshot(lane.inputConnectorSnapshot), [
902
+ externalIdField,
903
+ ...Object.values(mapping),
904
+ ...collectFilterRuleFields(filterRules),
905
+ ]), [externalIdField, filterRules, lane.inputConnectorSnapshot, mapping]);
906
+ const nextUpdate = buildInputRouteUpdate(lane.laneType, mapping, filterRules, externalIdField, promptVariables);
907
+ const nextSignature = inputRouteSignature(nextUpdate);
908
+ const hasDraft = nextSignature !== savedSignature;
909
+ const canEdit = lane.canEdit && Boolean(onUpdateInputRoute);
910
+ const showSave = canEdit && hasDraft;
911
+ const canSave = showSave && !pending;
912
+ function setMappingField(target, source) {
913
+ setMapping((current) => ({ ...current, [target]: source }));
914
+ setError(null);
915
+ }
916
+ function setMappingTarget(target, nextTarget) {
917
+ setMapping((current) => ({
918
+ ...current,
919
+ [target]: current[nextTarget] ?? '',
920
+ [nextTarget]: current[target] ?? '',
921
+ }));
922
+ setError(null);
923
+ }
924
+ async function saveInputRoute() {
925
+ if (!canEdit || pending || !onUpdateInputRoute)
926
+ return;
927
+ if (!nextUpdate || !nextSignature) {
928
+ setError(labels.inputRouteInvalid);
929
+ return;
930
+ }
931
+ if (nextSignature === savedSignature)
932
+ return;
933
+ setError(null);
934
+ try {
935
+ await onUpdateInputRoute(nextUpdate);
936
+ setSavedSignature(nextSignature);
937
+ }
938
+ catch (saveError) {
939
+ setError(getApiErrorMessage(saveError) ?? labels.inputRouteSaveFailed);
940
+ }
941
+ }
942
+ return (_jsxs("section", { className: "mt-4 rounded-lg border bg-card", style: { borderColor: token.bd }, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "size-2 rounded-full", style: { background: token.dot } }), _jsx("span", { className: "text-[12px] font-semibold", children: lane.title })] }), showSave ? (_jsxs(Button, { type: "button", size: "sm", onClick: saveInputRoute, disabled: !canSave, className: "h-7 px-2 text-[11.5px]", children: [_jsx(Save, { className: "size-3.5" }), pending ? labels.savePending : labels.save] })) : null] }), _jsxs("div", { className: "space-y-4 p-3", children: [_jsxs("div", { children: [_jsx("div", { className: "mb-2 text-[11px] font-medium text-muted-foreground", children: labels.fieldMapping }), _jsx(FieldMappingTable, { compact: true, fields: fieldOptions, promptVariables: promptVariables, externalIdField: externalIdField, mapping: mapping, readOnly: !canEdit || pending, testIdPrefix: `release-${lane.laneType}-input-route`, onExternalIdFieldChange: (value) => {
943
+ setExternalIdField(value);
944
+ setError(null);
945
+ }, onMappingChange: setMappingField, onMappingTargetChange: setMappingTarget })] }), _jsxs("div", { children: [_jsxs("div", { className: "mb-2 flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[11px] font-medium text-muted-foreground", children: labels.filterRules }), canEdit && !pending ? (_jsx(Button, { type: "button", size: "sm", variant: "ghost", className: "h-7 px-2 text-[11.5px]", onClick: () => setFilterEditorOpen((current) => !current), children: filterEditorOpen ? labels.inputRouteHideFilter : labels.inputRouteEditFilter })) : null] }), canEdit && !pending && filterEditorOpen ? (_jsx(FilterRulesBuilder, { compact: true, value: filterRules, fields: fieldOptions, onChange: (next) => {
946
+ setFilterRules(next);
947
+ setError(null);
948
+ } })) : (_jsx(SimpleFilterRulesView, { value: filterRules, labels: labels }))] }), error ? _jsx("p", { className: "text-[12px] text-destructive", children: error }) : null] })] }));
949
+ }
950
+ function IconTooltipButton({ label, icon: Icon, onClick, disabled, variant = 'outline', className, testId, }) {
951
+ return (_jsx(TooltipProvider, { delayDuration: 140, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { type: "button", size: "icon", variant: variant, className: cn('size-8', className), onClick: onClick, disabled: disabled, "aria-label": label, "data-testid": testId, children: _jsx(Icon, { className: "size-3.5" }) }) }), _jsx(TooltipContent, { side: "top", children: label })] }) }));
952
+ }
953
+ function CanaryLaneActions({ canary, labels, pending, onAddCanary, onStopCanary, onPromoteCanary, }) {
954
+ const [activeAction, setActiveAction] = useState(null);
955
+ const [error, setError] = useState(null);
956
+ const canPromote = canary?.status === 'running' && Boolean(onPromoteCanary);
957
+ const canStop = canary?.status === 'running' && Boolean(onStopCanary);
958
+ const canAddCanary = Boolean(onAddCanary);
959
+ const busy = pending || activeAction !== null;
960
+ const addCanaryLabel = canary ? labels.replaceCanary : labels.addCanary;
961
+ async function runAction(action, handler) {
962
+ if (!handler || !canary || busy)
963
+ return;
964
+ setError(null);
965
+ setActiveAction(action);
966
+ try {
967
+ await handler(canary);
968
+ }
969
+ catch (caught) {
970
+ setError(getApiErrorMessage(caught) ?? labels.canaryActionFailed);
971
+ }
972
+ finally {
973
+ setActiveAction(null);
974
+ }
975
+ }
976
+ if (!canPromote && !canStop && !canAddCanary)
977
+ return null;
978
+ return (_jsxs("div", { className: "flex min-w-0 flex-col items-end gap-1", "data-testid": "release-topology-canary-actions", children: [_jsxs("div", { className: "flex flex-wrap justify-end gap-1.5", children: [canPromote ? (_jsx(IconTooltipButton, { label: labels.promoteCanary, icon: Rocket, onClick: () => void runAction('promote', onPromoteCanary), disabled: busy, variant: "default", testId: "release-topology-promote-canary" })) : null, canAddCanary ? (_jsx(IconTooltipButton, { label: addCanaryLabel, icon: Plus, onClick: () => onAddCanary?.(), disabled: busy, variant: "outline", testId: "release-topology-add-canary" })) : null, canStop ? (_jsx(IconTooltipButton, { label: labels.stopCanary, icon: Square, onClick: () => void runAction('stop', onStopCanary), disabled: busy, variant: "outline", className: "text-destructive hover:text-destructive" })) : null] }), error ? _jsx("p", { className: "max-w-full truncate text-[11.5px] text-destructive", children: error }) : null] }));
979
+ }
980
+ function OutputRouteControl({ line, outputConnectors, outputConnectorsLoading, labels, pending, onUpdateOutputRoute, }) {
981
+ const [open, setOpen] = useState(false);
982
+ const upstreamFields = useMemo(() => extractInputFieldOptionsFromSnapshot(line.inputConnectorSnapshot), [line.inputConnectorSnapshot]);
983
+ const laneDrafts = [
984
+ line.production?.currentEvent
985
+ ? {
986
+ key: 'production',
987
+ title: labels.production,
988
+ tone: 'production',
989
+ canEdit: line.production.currentEvent.status === 'running',
990
+ connectorIds: line.production.currentEvent.outputConnectorIds,
991
+ outputMapping: line.productionOutputMapping,
992
+ outputSourceGroups: getOutputSourceGroups(line.production.currentEvent.promptVersionSnapshot, upstreamFields, labels),
993
+ }
994
+ : null,
995
+ line.canary
996
+ ? {
997
+ key: 'canary',
998
+ title: labels.canary,
999
+ tone: 'canary',
1000
+ canEdit: isAdjustableCanary(line.canary),
1001
+ connectorIds: line.canary.outputConnectorIds,
1002
+ outputMapping: line.canaryOutputMapping,
1003
+ outputSourceGroups: getOutputSourceGroups(line.canaryPromptVersionSnapshot, upstreamFields, labels),
1004
+ }
1005
+ : null,
1006
+ ];
1007
+ const lanes = laneDrafts.filter((lane) => lane !== null);
1008
+ if (lanes.length === 0) {
1009
+ return (_jsx("section", { className: "mt-4 rounded-lg border bg-card p-3 text-[12px] text-muted-foreground", children: labels.outputRouteNoLane }));
1010
+ }
1011
+ return (_jsxs("div", { className: "mt-4", children: [_jsxs(Button, { type: "button", className: "w-full justify-center", onClick: () => setOpen(true), children: [_jsx(Split, { className: "size-4" }), labels.outputRouteEdit] }), open ? (_jsx(OutputRouteDialog, { open: open, onOpenChange: setOpen, lanes: lanes, outputConnectors: outputConnectors, outputConnectorsLoading: outputConnectorsLoading, labels: labels, pending: pending, lineLabel: line.label, onUpdateOutputRoute: onUpdateOutputRoute })) : null] }));
1012
+ }
1013
+ function OutputRouteDialog({ open, onOpenChange, lanes, outputConnectors, outputConnectorsLoading, labels, pending, lineLabel, onUpdateOutputRoute, }) {
1014
+ const [drafts, setDrafts] = useState(() => Object.fromEntries(lanes.map((lane) => [lane.key, normalizeConnectorOutputRoutes(lane.outputMapping, lane.connectorIds)])));
1015
+ const [saving, setSaving] = useState(false);
1016
+ const [error, setError] = useState(null);
1017
+ const connectorById = useMemo(() => new Map(outputConnectors.map((connector) => [connector.id, connector])), [outputConnectors]);
1018
+ const laneEntries = lanes.map((lane) => {
1019
+ const routes = drafts[lane.key] ?? [];
1020
+ const initialRoutes = normalizeConnectorOutputRoutes(lane.outputMapping, lane.connectorIds);
1021
+ const cleanRoutes = cleanOutputRoutes(routes);
1022
+ const hasDraft = serializeOutputRoutes(cleanRoutes) !== serializeOutputRoutes(initialRoutes);
1023
+ return { lane, routes, cleanRoutes, hasDraft };
1024
+ });
1025
+ const busy = pending || saving;
1026
+ const changedEntries = laneEntries.filter((entry) => entry.hasDraft && entry.lane.canEdit);
1027
+ const hasReadonlyDraft = laneEntries.some((entry) => entry.hasDraft && !entry.lane.canEdit);
1028
+ const canSave = Boolean(onUpdateOutputRoute) && !busy && changedEntries.length > 0 && !hasReadonlyDraft;
1029
+ const productionConnectorCount = drafts.production?.length ?? 0;
1030
+ const canaryConnectorCount = drafts.canary?.length ?? 0;
1031
+ const mappingCount = laneEntries.reduce((total, entry) => total + entry.cleanRoutes.reduce((sum, route) => sum + route.outputMapping.length, 0), 0);
1032
+ function updateLaneRoutes(laneType, updater) {
1033
+ setDrafts((current) => ({ ...current, [laneType]: updater(current[laneType] ?? []) }));
1034
+ setError(null);
1035
+ }
1036
+ async function saveOutputRoutes() {
1037
+ const update = onUpdateOutputRoute;
1038
+ if (!canSave || !update)
1039
+ return;
1040
+ setSaving(true);
1041
+ setError(null);
1042
+ try {
1043
+ for (const entry of changedEntries) {
1044
+ await update({
1045
+ laneType: entry.lane.key,
1046
+ outputConnectorIds: entry.cleanRoutes.map((route) => route.connectorId),
1047
+ outputMapping: entry.cleanRoutes,
1048
+ });
1049
+ }
1050
+ onOpenChange(false);
1051
+ }
1052
+ catch (caught) {
1053
+ setError(getApiErrorMessage(caught) ?? labels.outputRouteSaveFailed);
1054
+ }
1055
+ finally {
1056
+ setSaving(false);
1057
+ }
1058
+ }
1059
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[88vh] max-w-[920px] gap-0 overflow-hidden rounded-[14px] p-0", children: [_jsxs(DialogHeader, { className: "border-b px-5 py-4 pr-12 text-left", children: [_jsx(DialogTitle, { className: "text-[16px]", children: labels.outputRouteDialogTitle }), _jsxs(DialogDescription, { className: "text-[12.5px]", children: [labels.outputRouteTitle, " \u00B7 ", _jsx("span", { className: "font-mono", children: lineLabel }), " \u00B7", ' ', labels.outputRouteDialogDescription] })] }), _jsx("div", { className: "max-h-[calc(88vh-136px)] overflow-y-auto", children: _jsx("div", { className: cn('grid grid-cols-1', lanes.length > 1 && 'lg:grid-cols-2'), children: laneEntries.map((entry, index) => (_jsx(OutputRouteLaneEditor, { lane: entry.lane, routes: entry.routes, canEdit: entry.lane.canEdit && Boolean(onUpdateOutputRoute), connectorById: connectorById, outputConnectors: outputConnectors, outputConnectorsLoading: outputConnectorsLoading, labels: labels, busy: busy, className: index > 0 ? 'lg:border-l' : undefined, onRoutesChange: (updater) => updateLaneRoutes(entry.lane.key, updater) }, entry.lane.key))) }) }), _jsxs("div", { className: "flex flex-col gap-3 border-t bg-muted/40 px-5 py-3 sm:flex-row sm:items-center", children: [_jsx("div", { className: "min-w-0 text-[12px] text-muted-foreground", children: labels.outputRouteFooterSummary(productionConnectorCount, canaryConnectorCount, mappingCount) }), error ? _jsx("div", { className: "text-[12px] text-destructive", children: error }) : null, _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), disabled: busy, children: labels.cancel }), _jsxs(Button, { type: "button", onClick: saveOutputRoutes, disabled: !canSave, children: [_jsx(Save, { className: "size-4" }), busy ? labels.savePending : labels.outputRouteSave] })] })] })] }) }));
1060
+ }
1061
+ function OutputRouteLaneEditor({ lane, routes, canEdit, connectorById, outputConnectors, outputConnectorsLoading, labels, busy, className, onRoutesChange, }) {
1062
+ const token = TONE_STYLES[lane.tone];
1063
+ const selectedConnectorIds = new Set(routes.map((route) => route.connectorId));
1064
+ const addableConnectors = outputConnectors.filter((connector) => {
1065
+ if (selectedConnectorIds.has(connector.id))
1066
+ return false;
1067
+ return true;
1068
+ });
1069
+ function addConnector(connectorId) {
1070
+ onRoutesChange((current) => current.some((route) => route.connectorId === connectorId)
1071
+ ? current
1072
+ : [...current, { connectorId, outputMapping: [{ source: '', target: '' }] }]);
1073
+ }
1074
+ function removeConnector(connectorId) {
1075
+ onRoutesChange((current) => current.filter((route) => route.connectorId !== connectorId));
1076
+ }
1077
+ function addMapping(connectorId) {
1078
+ onRoutesChange((current) => current.map((route) => route.connectorId === connectorId
1079
+ ? { ...route, outputMapping: [...route.outputMapping, { source: '', target: '' }] }
1080
+ : route));
1081
+ }
1082
+ function setMappingField(connectorId, index, field, value) {
1083
+ onRoutesChange((current) => current.map((route) => route.connectorId === connectorId
1084
+ ? {
1085
+ ...route,
1086
+ outputMapping: route.outputMapping.map((item, itemIndex) => itemIndex === index ? { ...item, [field]: value } : item),
1087
+ }
1088
+ : route));
1089
+ }
1090
+ function removeMapping(connectorId, index) {
1091
+ onRoutesChange((current) => current.map((route) => route.connectorId === connectorId
1092
+ ? { ...route, outputMapping: route.outputMapping.filter((_item, itemIndex) => itemIndex !== index) }
1093
+ : route));
1094
+ }
1095
+ return (_jsxs("section", { className: cn('min-w-0 px-5 py-4', className), children: [_jsxs("div", { className: "mb-2 flex items-baseline gap-2 border-b-2 pb-3", style: { borderColor: token.dot }, children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { background: token.dot } }), _jsx("span", { className: "truncate text-[15px] font-semibold", children: lane.title }), _jsx("span", { className: "font-mono text-[11.5px] text-muted-foreground", children: lane.key })] }), _jsx("span", { className: "ml-auto whitespace-nowrap text-[11.5px] text-muted-foreground", children: labels.outputRouteConnectorCount(routes.length) })] }), !canEdit ? (_jsx("div", { className: "mb-2 text-[11.5px] text-muted-foreground", children: labels.outputRouteLaneReadonly })) : null, _jsx("div", { className: "divide-y divide-dashed", children: routes.length === 0 ? (_jsx("div", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-8 text-center text-[12px] text-muted-foreground", children: labels.outputRouteNoSelectedConnector })) : (routes.map((route) => {
1096
+ const connector = connectorById.get(route.connectorId);
1097
+ const connectorName = connector?.name ?? route.connectorId;
1098
+ const connectorMeta = connector
1099
+ ? `${connector.type} · ${connector.configSummary || connector.healthStatus}`
1100
+ : route.connectorId;
1101
+ return (_jsxs("div", { className: "group py-3 first:pt-1", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate font-mono text-[13.5px] font-semibold", title: connectorName, children: connectorName }), _jsx("div", { className: "mt-0.5 truncate font-mono text-[11.5px] text-muted-foreground", title: connectorMeta, children: connectorMeta })] }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 opacity-0 transition-opacity group-hover:opacity-100", title: labels.outputRouteRemoveConnector, "aria-label": labels.outputRouteRemoveConnector, disabled: !canEdit || busy, onClick: () => removeConnector(route.connectorId), children: _jsx(X, { className: "size-3.5" }) })] }), _jsx("div", { className: "mt-2 space-y-0", children: route.outputMapping.length === 0 ? (_jsx("div", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground", children: labels.outputRoutePassThrough })) : (route.outputMapping.map((item, index) => (_jsxs("div", { className: "grid grid-cols-[minmax(0,1.18fr)_16px_minmax(0,0.82fr)_24px] items-center gap-1 border-t border-dashed py-1 first:border-t-0", children: [_jsx(OutputSourcePicker, { value: item.source, sourceGroups: lane.outputSourceGroups, labels: labels, disabled: !canEdit || busy, onChange: (value) => setMappingField(route.connectorId, index, 'source', value) }), _jsx("span", { className: "text-center text-[12px] text-muted-foreground", children: "\u2192" }), _jsx("input", { value: item.target, onChange: (event) => setMappingField(route.connectorId, index, 'target', event.target.value), placeholder: labels.outputRouteTarget, disabled: !canEdit || busy, className: "h-8 min-w-0 border-0 border-b border-dashed bg-transparent px-1 font-mono text-[12.5px] outline-none transition-colors placeholder:font-sans placeholder:italic placeholder:text-muted-foreground hover:border-muted-foreground focus:border-primary focus:border-solid disabled:cursor-not-allowed disabled:opacity-60" }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-6 opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100", title: labels.removeMapping, "aria-label": labels.removeMapping, disabled: !canEdit || busy, onClick: () => removeMapping(route.connectorId, index), children: _jsx(X, { className: "size-3.5" }) })] }, index)))) }), _jsxs("button", { type: "button", className: "mt-1 inline-flex items-center gap-1 rounded-md px-1 py-1 text-[12px] text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60", disabled: !canEdit || busy, onClick: () => addMapping(route.connectorId), children: [_jsx(Plus, { className: "size-3" }), labels.addMapping] })] }, route.connectorId));
1102
+ })) }), _jsx(OutputConnectorAddPopover, { addableConnectors: addableConnectors, disabled: !canEdit || busy, loading: outputConnectorsLoading, labels: labels, onAdd: addConnector })] }));
1103
+ }
1104
+ function OutputConnectorAddPopover({ addableConnectors, disabled, loading, labels, onAdd, }) {
1105
+ const [open, setOpen] = useState(false);
1106
+ return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", disabled: disabled || loading, className: "mt-3 flex h-10 w-full items-center justify-center gap-1.5 rounded-md border border-dashed bg-transparent text-[12.5px] font-medium text-muted-foreground transition-colors hover:border-ring hover:bg-muted/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60", children: [_jsx(Plus, { className: "size-3.5" }), loading ? labels.loading : labels.outputRouteAddConnector] }) }), _jsxs(PopoverContent, { align: "center", sideOffset: 6, className: "w-[300px] p-1", onWheelCapture: (event) => event.stopPropagation(), onTouchMoveCapture: (event) => event.stopPropagation(), children: [_jsx("div", { className: "px-2 py-1.5 text-[11px] font-semibold text-muted-foreground", children: labels.outputRouteConnectorPickerTitle }), addableConnectors.length === 0 ? (_jsx("div", { className: "px-2 py-4 text-center text-[12px] text-muted-foreground", children: labels.outputRouteNoMoreConnectors })) : (_jsx("div", { className: "max-h-[280px] overflow-auto overscroll-contain", onWheelCapture: (event) => event.stopPropagation(), onTouchMoveCapture: (event) => event.stopPropagation(), children: addableConnectors.map((connector) => (_jsx("button", { type: "button", className: "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted", onClick: () => {
1107
+ onAdd(connector.id);
1108
+ setOpen(false);
1109
+ }, children: _jsx("span", { className: "min-w-0 flex-1 truncate font-mono text-[12.5px]", children: connector.name }) }, connector.id))) }))] })] }));
1110
+ }
1111
+ function OutputSourcePicker({ value, sourceGroups, labels, disabled, onChange, }) {
1112
+ const [open, setOpen] = useState(false);
1113
+ const [query, setQuery] = useState('');
1114
+ const groups = sourceGroupsForValue(value, sourceGroups, labels);
1115
+ const visibleGroups = filterOutputSourceGroups(groups, query);
1116
+ const groupKey = outputSourceGroupKeyForValue(value, groups);
1117
+ return (_jsxs(Popover, { open: open, onOpenChange: (nextOpen) => {
1118
+ setOpen(nextOpen);
1119
+ if (!nextOpen)
1120
+ setQuery('');
1121
+ }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", disabled: disabled, className: cn('flex h-8 min-w-0 items-center gap-1.5 rounded-md border border-transparent px-1.5 font-mono text-[12.5px] transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-60', !value && 'font-sans italic text-muted-foreground', groupKey === 'upstream' && 'text-[var(--status-running-fg)]'), children: [groupKey ? (_jsx("span", { className: cn('size-1.5 shrink-0 rounded-[2px]', outputSourceGroupDotClass(groupKey)) })) : null, _jsx("span", { className: "min-w-0 flex-1 truncate text-left", children: value || labels.outputRouteSourcePlaceholder }), _jsx(ChevronDown, { className: cn('size-3 shrink-0 text-muted-foreground opacity-0', open && 'opacity-100') })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 6, className: "w-[300px] p-0", onWheelCapture: (event) => event.stopPropagation(), onTouchMoveCapture: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 border-b px-2 py-1.5", children: [_jsx(Search, { className: "size-3.5 text-muted-foreground" }), _jsx("input", { value: query, onChange: (event) => setQuery(event.target.value), placeholder: labels.outputRouteSourceSearch, className: "h-7 min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground" })] }), _jsx("div", { className: "max-h-[300px] overflow-auto overscroll-contain p-1", onWheelCapture: (event) => event.stopPropagation(), onTouchMoveCapture: (event) => event.stopPropagation(), children: visibleGroups.length === 0 ? (_jsx("div", { className: "px-2 py-4 text-center text-[12px] text-muted-foreground", children: labels.outputRouteSourceEmpty })) : (visibleGroups.map((group, groupIndex) => (_jsxs("div", { className: cn(groupIndex > 0 && 'border-t pt-1'), children: [_jsxs("div", { className: "flex items-center gap-2 px-2 py-1.5 text-[11px] font-semibold text-muted-foreground", children: [_jsx("span", { className: cn('size-1.5 rounded-[2px]', outputSourceGroupDotClass(group.key)) }), _jsx("span", { children: group.label }), group.badge ? (_jsx("span", { className: "ml-auto rounded border border-[var(--status-running-bd)] bg-[var(--status-running-bg)] px-1.5 py-0.5 font-mono text-[9.5px] font-semibold text-[var(--status-running-fg)]", children: group.badge })) : null] }), group.options.map((option) => (_jsxs("button", { type: "button", className: cn('flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left font-mono text-[12.5px] transition-colors hover:bg-muted', option.value === value && 'bg-primary/5'), onClick: () => {
1122
+ onChange(option.value);
1123
+ setOpen(false);
1124
+ }, children: [_jsx(Check, { className: cn('mt-0.5 size-3 shrink-0 text-primary opacity-0', option.value === value && 'opacity-100') }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: option.value })] }, option.value)))] }, group.key)))) })] })] }));
1125
+ }
1126
+ function sourceGroupsForValue(value, sourceGroups, labels) {
1127
+ if (!value || sourceGroups.some((group) => group.options.some((option) => option.value === value))) {
1128
+ return sourceGroups;
1129
+ }
1130
+ return [
1131
+ {
1132
+ key: 'legacy',
1133
+ label: labels.outputRouteSourceLegacy,
1134
+ options: [{ value, label: value, description: labels.outputRouteSourceLegacy }],
1135
+ },
1136
+ ...sourceGroups,
1137
+ ];
1138
+ }
1139
+ function filterOutputSourceGroups(sourceGroups, query) {
1140
+ const normalized = query.trim().toLowerCase();
1141
+ if (!normalized)
1142
+ return sourceGroups;
1143
+ return sourceGroups
1144
+ .map((group) => ({
1145
+ ...group,
1146
+ options: group.options.filter((option) => [option.value, option.label, option.description].join(' ').toLowerCase().includes(normalized)),
1147
+ }))
1148
+ .filter((group) => group.options.length > 0);
1149
+ }
1150
+ function outputSourceGroupKeyForValue(value, sourceGroups) {
1151
+ if (!value)
1152
+ return null;
1153
+ return sourceGroups.find((group) => group.options.some((option) => option.value === value))?.key ?? null;
1154
+ }
1155
+ function outputSourceGroupDotClass(groupKey) {
1156
+ switch (groupKey) {
1157
+ case 'upstream':
1158
+ return 'bg-[var(--status-running-dot)]';
1159
+ case 'model':
1160
+ return 'bg-primary';
1161
+ case 'meta':
1162
+ return 'bg-[var(--status-canary-dot)]';
1163
+ case 'legacy':
1164
+ return 'bg-muted-foreground';
1165
+ }
1166
+ }
473
1167
  function TrafficRatioControl({ line, labels, onUpdateTrafficRatio, pending, }) {
474
1168
  const canary = line.canary;
1169
+ const isDualRun = canary?.trafficMode === 'dual_run';
475
1170
  const maxTrafficPercent = 100;
476
1171
  const currentPercent = canary ? Math.round(canary.trafficRatio * 100) : 0;
477
1172
  const [trafficPercent, setTrafficPercent] = useState(canary ? trafficPercentFromRatio(canary.trafficRatio) : '');
@@ -523,13 +1218,16 @@ function TrafficRatioControl({ line, labels, onUpdateTrafficRatio, pending, }) {
523
1218
  setTrafficPercent(parsed === null ? '' : String(100 - parsed));
524
1219
  setTrafficError(null);
525
1220
  }
526
- return (_jsxs("section", { className: "mt-4 rounded-lg border bg-card", children: [_jsx("div", { className: "border-b px-3 py-2 text-[12px] font-semibold", children: labels.trafficBox }), _jsxs("div", { className: "space-y-3 p-3", children: [_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsxs("div", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: labels.productionTrafficLabel }), _jsxs("label", { className: "mt-1 flex items-center gap-2", children: [_jsx(Input, { type: "number", min: 0, max: 100, value: productionPercent, onChange: (event) => setProductionPercentText(event.target.value), onKeyDown: (event) => {
1221
+ return (_jsxs("section", { className: "mt-4 rounded-lg border bg-card", children: [_jsx("div", { className: "border-b px-3 py-2 text-[12px] font-semibold", children: labels.trafficBox }), _jsxs("div", { className: "space-y-3 p-3", children: [isDualRun ? (_jsxs("div", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: labels.canaryTrafficLabel }), _jsxs("label", { className: "mt-1 flex items-center gap-2", children: [_jsx(Input, { type: "number", min: 0, max: 100, value: trafficPercent, onChange: (event) => setCanaryPercentText(event.target.value), onKeyDown: (event) => {
1222
+ if (event.key === 'Enter')
1223
+ void saveTrafficRatio();
1224
+ }, disabled: !canEditTraffic || isSavingTraffic, "aria-label": labels.canaryTrafficLabel, className: "h-8 font-mono text-xs" }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: "%" })] })] })) : (_jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsxs("div", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: labels.productionTrafficLabel }), _jsxs("label", { className: "mt-1 flex items-center gap-2", children: [_jsx(Input, { type: "number", min: 0, max: 100, value: productionPercent, onChange: (event) => setProductionPercentText(event.target.value), onKeyDown: (event) => {
527
1225
  if (event.key === 'Enter')
528
1226
  void saveTrafficRatio();
529
1227
  }, disabled: !canEditTraffic || isSavingTraffic, "aria-label": labels.productionTrafficLabel, className: "h-8 font-mono text-xs" }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: "%" })] })] }), _jsxs("div", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: labels.canaryTrafficLabel }), _jsxs("label", { className: "mt-1 flex items-center gap-2", children: [_jsx(Input, { type: "number", min: 0, max: 100, value: trafficPercent, onChange: (event) => setCanaryPercentText(event.target.value), onKeyDown: (event) => {
530
1228
  if (event.key === 'Enter')
531
1229
  void saveTrafficRatio();
532
- }, disabled: !canEditTraffic || isSavingTraffic, "aria-label": labels.canaryTrafficLabel, className: "h-8 font-mono text-xs" }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: "%" })] })] })] }), canary ? (_jsxs(_Fragment, { children: [_jsx("input", { type: "range", min: 0, max: maxTrafficPercent, step: 1, value: parsedTrafficPercent ?? currentPercent, "aria-label": labels.trafficPercentAriaLabel, onChange: (event) => setCanaryPercentText(event.target.value), disabled: !canEditTraffic || isSavingTraffic, className: "w-full accent-primary disabled:opacity-50" }), _jsxs("div", { className: "flex items-center justify-between font-mono text-[11px] text-muted-foreground", children: [_jsxs("span", { children: [labels.productionTrafficLabel, " 100%"] }), _jsxs("span", { children: [labels.canaryTrafficLabel, " 100%"] })] }), _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", size: "sm", onClick: saveTrafficRatio, disabled: !canSaveTraffic, children: [_jsx(Save, { className: "size-3.5" }), isSavingTraffic ? labels.savePending : labels.save] }) })] })) : (_jsx("div", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground", children: labels.noCanaryToAdjust })), trafficError ? _jsx("p", { className: "text-[12px] text-destructive", children: trafficError }) : null] })] }));
1230
+ }, disabled: !canEditTraffic || isSavingTraffic, "aria-label": labels.canaryTrafficLabel, className: "h-8 font-mono text-xs" }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: "%" })] })] })] })), canary ? (_jsxs(_Fragment, { children: [_jsx("input", { type: "range", min: 0, max: maxTrafficPercent, step: 1, value: parsedTrafficPercent ?? currentPercent, "aria-label": labels.trafficPercentAriaLabel, onChange: (event) => setCanaryPercentText(event.target.value), disabled: !canEditTraffic || isSavingTraffic, className: "w-full accent-primary disabled:opacity-50" }), _jsx("div", { className: "flex items-center justify-between font-mono text-[11px] text-muted-foreground", children: isDualRun ? (_jsxs(_Fragment, { children: [_jsx("span", { children: "0%" }), _jsxs("span", { children: [labels.canaryTrafficLabel, " 100%"] })] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { children: [labels.productionTrafficLabel, " 100%"] }), _jsxs("span", { children: [labels.canaryTrafficLabel, " 100%"] })] })) }), _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", size: "sm", onClick: saveTrafficRatio, disabled: !canSaveTraffic, children: [_jsx(Save, { className: "size-3.5" }), isSavingTraffic ? labels.savePending : labels.save] }) })] })) : (_jsx("div", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground", children: labels.noCanaryToAdjust })), trafficError ? _jsx("p", { className: "text-[12px] text-destructive", children: trafficError }) : null] })] }));
533
1231
  }
534
1232
  function numberText(value) {
535
1233
  return typeof value === 'number' && Number.isFinite(value) ? String(value) : '';
@@ -542,6 +1240,7 @@ function runConfigDraftFromRecord(config, modelId) {
542
1240
  tpmLimit: numberText(record.tpmLimit),
543
1241
  concurrency: numberText(record.concurrency),
544
1242
  temperature: numberText(record.temperature) || '0.3',
1243
+ stopConditions: readCanaryStopConditions(record.stopConditions),
545
1244
  };
546
1245
  }
547
1246
  function parsePositiveInteger(value) {
@@ -552,7 +1251,7 @@ function parseTemperature(value) {
552
1251
  const parsed = Number(value);
553
1252
  return Number.isFinite(parsed) && parsed >= 0 && parsed <= 2 ? parsed : null;
554
1253
  }
555
- function buildRunConfigUpdate(laneType, draft) {
1254
+ function buildRunConfigUpdate(laneType, draft, recordMode, recordCategories) {
556
1255
  const rpmLimit = parsePositiveInteger(draft.rpmLimit);
557
1256
  const tpmLimit = parsePositiveInteger(draft.tpmLimit);
558
1257
  const concurrency = parsePositiveInteger(draft.concurrency);
@@ -561,19 +1260,41 @@ function buildRunConfigUpdate(laneType, draft) {
561
1260
  const temperature = parseTemperature(draft.temperature);
562
1261
  if (temperature === null)
563
1262
  return null;
1263
+ const baseRunConfig = {
1264
+ rpmLimit,
1265
+ tpmLimit,
1266
+ concurrency,
1267
+ temperature,
1268
+ };
1269
+ if (laneType === 'canary') {
1270
+ return {
1271
+ laneType,
1272
+ modelId: draft.modelId || undefined,
1273
+ recordMode,
1274
+ recordCategories,
1275
+ runConfig: {
1276
+ ...baseRunConfig,
1277
+ ...(draft.stopConditions ? { stopConditions: draft.stopConditions } : {}),
1278
+ },
1279
+ };
1280
+ }
564
1281
  return {
565
1282
  laneType,
566
1283
  modelId: draft.modelId || undefined,
567
- runConfig: {
568
- rpmLimit,
569
- tpmLimit,
570
- concurrency,
571
- temperature,
572
- },
1284
+ recordMode,
1285
+ recordCategories,
1286
+ runConfig: baseRunConfig,
573
1287
  };
574
1288
  }
575
1289
  function runConfigSignature(input) {
576
- return input ? JSON.stringify({ modelId: input.modelId ?? null, runConfig: input.runConfig }) : null;
1290
+ return input
1291
+ ? JSON.stringify({
1292
+ modelId: input.modelId ?? null,
1293
+ recordMode: input.recordMode ?? null,
1294
+ recordCategories: input.recordCategories ?? null,
1295
+ runConfig: input.runConfig,
1296
+ })
1297
+ : null;
577
1298
  }
578
1299
  function modelOptionFromProjectModel(model) {
579
1300
  return {
@@ -613,16 +1334,17 @@ function formatModelLimitValue(value, labels) {
613
1334
  function modelOptionLabel(option) {
614
1335
  return [option.name, option.providerModelId].filter(Boolean).join(' · ');
615
1336
  }
616
- function RuntimeConfigEditor({ laneType, config, currentModelId, currentModelName, models, modelsLoading, labels, canEdit, pending, onUpdateRunConfig, }) {
1337
+ function RuntimeConfigEditor({ laneType, config, currentModelId, currentModelName, recordMode, models, modelsLoading, labels, canEdit, pending, onUpdateRunConfig, hideModel = false, className, }) {
617
1338
  const modelOptions = useMemo(() => buildRuntimeModelOptions(models, currentModelId, currentModelName), [currentModelId, currentModelName, models]);
618
1339
  const [draft, setDraft] = useState(() => runConfigDraftFromRecord(config, currentModelId));
619
1340
  const [error, setError] = useState(null);
620
- const initialSignature = runConfigSignature(buildRunConfigUpdate(laneType, runConfigDraftFromRecord(config, currentModelId)));
1341
+ const initialSignature = runConfigSignature(buildRunConfigUpdate(laneType, runConfigDraftFromRecord(config, currentModelId), recordMode));
621
1342
  const [savedSignature, setSavedSignature] = useState(initialSignature);
622
- const nextUpdate = buildRunConfigUpdate(laneType, draft);
1343
+ const nextUpdate = buildRunConfigUpdate(laneType, draft, recordMode);
623
1344
  const nextSignature = runConfigSignature(nextUpdate);
624
- const isChanged = nextSignature !== null && nextSignature !== savedSignature;
625
- const canSubmit = canEdit && Boolean(onUpdateRunConfig) && !pending && (isChanged || nextUpdate === null);
1345
+ const hasDraftChange = nextSignature !== savedSignature;
1346
+ const showSave = canEdit && Boolean(onUpdateRunConfig) && hasDraftChange;
1347
+ const canSubmit = showSave && !pending;
626
1348
  const selectedModel = modelOptions.find((model) => model.id === draft.modelId) ?? null;
627
1349
  const rpmModelLimit = selectedModel ? labels.modelLimit(formatModelLimitValue(selectedModel.rpmLimit, labels)) : '—';
628
1350
  const tpmModelLimit = selectedModel ? labels.modelLimit(formatModelLimitValue(selectedModel.tpmLimit, labels)) : '—';
@@ -648,14 +1370,125 @@ function RuntimeConfigEditor({ laneType, config, currentModelId, currentModelNam
648
1370
  setError(getApiErrorMessage(saveError) ?? labels.runConfigUpdateFailed);
649
1371
  }
650
1372
  }
651
- return (_jsxs("section", { className: "mt-4 rounded-lg border bg-card", children: [_jsx("div", { className: "border-b px-3 py-2 text-[12px] font-semibold", children: labels.runConfigTitle }), _jsxs("div", { className: "space-y-3 p-3", children: [_jsxs("div", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: labels.model }), _jsxs(Select, { value: draft.modelId, onValueChange: (value) => setField('modelId', value), disabled: !canEdit || pending || modelOptions.length === 0, children: [_jsx(SelectTrigger, { className: "mt-1 h-8 bg-background text-xs", "aria-label": labels.model, children: _jsx(SelectValue, { placeholder: modelsLoading ? labels.loading : labels.runConfigModelUnavailable }) }), _jsx(SelectContent, { children: modelOptions.map((option) => (_jsx(SelectItem, { value: option.id, children: modelOptionLabel(option) }, option.id))) })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(RuntimeNumberField, { label: labels.rpmLimit, hint: rpmModelLimit, value: draft.rpmLimit, min: 1, step: 1, disabled: !canEdit || pending, onChange: (value) => setField('rpmLimit', value), onCommit: saveRunConfig }), _jsx(RuntimeNumberField, { label: labels.tpmLimit, hint: tpmModelLimit, value: draft.tpmLimit, min: 1, step: 1, disabled: !canEdit || pending, onChange: (value) => setField('tpmLimit', value), onCommit: saveRunConfig }), _jsx(RuntimeNumberField, { label: labels.concurrency, value: draft.concurrency, min: 1, step: 1, disabled: !canEdit || pending, onChange: (value) => setField('concurrency', value), onCommit: saveRunConfig }), _jsx(RuntimeNumberField, { label: labels.temperature, value: draft.temperature, min: 0, max: 2, step: 0.1, disabled: !canEdit || pending, onChange: (value) => setField('temperature', value), onCommit: saveRunConfig })] }), error ? _jsx("p", { className: "text-[12px] text-destructive", children: error }) : null, _jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", size: "sm", onClick: saveRunConfig, disabled: !canSubmit, children: [_jsx(Save, { className: "size-3.5" }), pending ? labels.savePending : labels.save] }) })] })] }));
1373
+ return (_jsxs("section", { className: cn('rounded-lg border bg-card', className), children: [_jsxs("div", { className: "flex min-h-10 items-center justify-between gap-2 border-b px-3 py-2", children: [_jsx("div", { className: "text-[12px] font-semibold", children: labels.runConfigTitle }), showSave ? (_jsxs(Button, { type: "button", size: "sm", className: "h-7 px-2 text-[11.5px]", onClick: saveRunConfig, disabled: !canSubmit, children: [_jsx(Save, { className: "size-3.5" }), pending ? labels.savePending : labels.save] })) : null] }), _jsxs("div", { className: "space-y-3 p-3", children: [hideModel ? null : (_jsxs("div", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsx("div", { className: "text-[11px] font-medium text-muted-foreground", children: labels.model }), _jsxs(Select, { value: draft.modelId, onValueChange: (value) => setField('modelId', value), disabled: !canEdit || pending || modelOptions.length === 0, children: [_jsx(SelectTrigger, { className: "mt-1 h-8 bg-background text-xs", "aria-label": labels.model, children: _jsx(SelectValue, { placeholder: modelsLoading ? labels.loading : labels.runConfigModelUnavailable }) }), _jsx(SelectContent, { children: modelOptions.map((option) => (_jsx(SelectItem, { value: option.id, children: modelOptionLabel(option) }, option.id))) })] })] })), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(RuntimeNumberField, { label: labels.tpmLimit, hint: tpmModelLimit, value: draft.tpmLimit, min: 1, step: 1, disabled: !canEdit || pending, onChange: (value) => setField('tpmLimit', value), onCommit: saveRunConfig }), _jsx(RuntimeNumberField, { label: labels.rpmLimit, hint: rpmModelLimit, value: draft.rpmLimit, min: 1, step: 1, disabled: !canEdit || pending, onChange: (value) => setField('rpmLimit', value), onCommit: saveRunConfig }), _jsx(RuntimeNumberField, { label: labels.concurrency, value: draft.concurrency, min: 1, step: 1, disabled: !canEdit || pending, onChange: (value) => setField('concurrency', value), onCommit: saveRunConfig }), _jsx(RuntimeNumberField, { label: labels.temperature, value: draft.temperature, min: 0, max: 2, step: 0.1, disabled: !canEdit || pending, onChange: (value) => setField('temperature', value), onCommit: saveRunConfig })] }), error ? _jsx("p", { className: "text-[12px] text-destructive", children: error }) : null] })] }));
652
1374
  }
653
1375
  function RuntimeNumberField({ label, hint, value, min, max, step, disabled, onChange, onCommit, }) {
654
- return (_jsxs("label", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsxs("span", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[11px] font-medium text-muted-foreground", children: label }), hint ? _jsx("span", { className: "truncate text-right text-[10.5px] text-muted-foreground", children: hint }) : null] }), _jsx(Input, { type: "number", min: min, max: max, step: step, value: value, onChange: (event) => onChange(event.target.value), onBlur: onCommit, onKeyDown: (event) => {
1376
+ return (_jsxs("label", { className: "rounded-md border bg-muted/40 px-3 py-2", children: [_jsxs("span", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[11px] font-medium text-muted-foreground", children: label }), hint ? _jsx("span", { className: "truncate text-right text-[10.5px] text-muted-foreground", children: hint }) : null] }), _jsx(Input, { type: "number", min: min, max: max, step: step, value: value, onChange: (event) => onChange(event.target.value), onKeyDown: (event) => {
655
1377
  if (event.key === 'Enter')
656
1378
  onCommit();
657
1379
  }, disabled: disabled, "aria-label": label, className: "mt-1 h-8 font-mono text-xs" })] }));
658
1380
  }
1381
+ function resourceDetailHref(resource, id, versionId) {
1382
+ if (!id)
1383
+ return null;
1384
+ const encodedId = encodeURIComponent(id);
1385
+ if (resource === 'model')
1386
+ return `/models/${encodedId}/edit`;
1387
+ const query = versionId ? `?version=${encodeURIComponent(versionId)}` : '';
1388
+ return `/prompts/${encodedId}${query}`;
1389
+ }
1390
+ function LinkedInspectorValue({ href, value }) {
1391
+ const display = toDisplayValue(value);
1392
+ if (!href || !value) {
1393
+ return (_jsx("span", { className: "block truncate", title: display, children: display }));
1394
+ }
1395
+ return (_jsx(Link, { href: href, className: "block truncate text-primary underline-offset-2 hover:underline", title: display, children: display }));
1396
+ }
1397
+ function editableRecordMode(mode) {
1398
+ return mode === 'correct_only' ? 'selected_categories' : mode;
1399
+ }
1400
+ function normalizeRecordCategories(categories, options) {
1401
+ const allowed = new Set(options);
1402
+ const seen = new Set();
1403
+ const normalized = [];
1404
+ for (const category of categories) {
1405
+ const trimmed = category.trim();
1406
+ if (!trimmed || seen.has(trimmed))
1407
+ continue;
1408
+ if (allowed.size > 0 && !allowed.has(trimmed))
1409
+ continue;
1410
+ seen.add(trimmed);
1411
+ normalized.push(trimmed);
1412
+ }
1413
+ return normalized;
1414
+ }
1415
+ function recordSettingsSignature(mode, categories) {
1416
+ return JSON.stringify({
1417
+ mode: editableRecordMode(mode),
1418
+ categories: editableRecordMode(mode) === 'selected_categories' ? [...categories].sort() : [],
1419
+ });
1420
+ }
1421
+ function recordCategoriesDraftFromState(mode, categories, options) {
1422
+ if (options.length === 0)
1423
+ return [];
1424
+ const normalized = normalizeRecordCategories(categories, options);
1425
+ return editableRecordMode(mode) === 'selected_categories' && normalized.length > 0 ? normalized : options;
1426
+ }
1427
+ function recordModeFromDraftCategories(categories, options) {
1428
+ if (options.length === 0 || categories.length === options.length)
1429
+ return 'all';
1430
+ return 'selected_categories';
1431
+ }
1432
+ function recordCategoryOptionsFromSnapshot(snapshot, savedCategories) {
1433
+ const outputSchema = snapshot['outputSchema'] ?? null;
1434
+ const derived = deriveClassificationOptionsFromPromptOutputSchema(outputSchema);
1435
+ return Array.from(new Set([...derived, ...savedCategories].map((category) => category.trim()).filter(Boolean)));
1436
+ }
1437
+ function RecordCategorySelector({ value, options, disabled, labels, onChange, }) {
1438
+ const allSelected = options.length > 0 && options.every((option) => value.includes(option));
1439
+ if (options.length === 0) {
1440
+ return (_jsx("div", { className: "rounded-md border border-dashed bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground", children: labels.recordCategoriesEmpty }));
1441
+ }
1442
+ return (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex min-h-7 items-center justify-between gap-2", children: [_jsx("span", { className: "text-[11.5px] text-muted-foreground", children: labels.recordCategoriesSelected(value.length) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 px-2 text-[11.5px]", disabled: disabled, onClick: () => onChange(allSelected ? [] : options), children: allSelected ? labels.recordCategoriesClear : labels.recordCategoriesSelectAll })] }), _jsx("div", { className: "flex flex-wrap gap-1.5 rounded-md border bg-muted/30 p-2", children: options.map((option) => {
1443
+ const selected = value.includes(option);
1444
+ return (_jsxs("button", { type: "button", disabled: disabled, "aria-pressed": selected, onClick: () => onChange(selected ? value.filter((item) => item !== option) : [...value, option]), className: cn('inline-flex h-7 min-w-0 items-center gap-1 rounded-full border px-2.5 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60', selected
1445
+ ? 'border-primary bg-primary text-primary-foreground'
1446
+ : 'border-border bg-background text-muted-foreground hover:bg-muted'), children: [selected ? _jsx(Check, { className: "size-3", strokeWidth: 3 }) : null, _jsx("span", { className: "max-w-[140px] truncate", children: option })] }, option));
1447
+ }) })] }));
1448
+ }
1449
+ function RecordModeMetadataCard({ laneType, config, modelId, recordMode, recordCategories, promptVersionSnapshot, createdAt, updatedAt, labels, formatDateTimeOrDash, canEdit, pending, onUpdateRunConfig, }) {
1450
+ const recordCategoryOptions = useMemo(() => recordCategoryOptionsFromSnapshot(promptVersionSnapshot, recordCategories), [promptVersionSnapshot, recordCategories]);
1451
+ const initialCategories = recordCategoriesDraftFromState(recordMode, recordCategories, recordCategoryOptions);
1452
+ const [draftRecordCategories, setDraftRecordCategories] = useState(initialCategories);
1453
+ const initialMode = recordModeFromDraftCategories(initialCategories, recordCategoryOptions);
1454
+ const initialRecordCategories = initialMode === 'selected_categories' ? initialCategories : [];
1455
+ const [savedSignature, setSavedSignature] = useState(recordSettingsSignature(initialMode, initialRecordCategories));
1456
+ const [error, setError] = useState(null);
1457
+ const normalizedDraftCategories = normalizeRecordCategories(draftRecordCategories, recordCategoryOptions);
1458
+ const draftRecordMode = recordModeFromDraftCategories(normalizedDraftCategories, recordCategoryOptions);
1459
+ const draftRecordCategoriesForSave = draftRecordMode === 'selected_categories' ? normalizedDraftCategories : [];
1460
+ const nextUpdate = buildRunConfigUpdate(laneType, runConfigDraftFromRecord(config, modelId), draftRecordMode, draftRecordCategoriesForSave);
1461
+ const nextSignature = recordSettingsSignature(draftRecordMode, draftRecordCategoriesForSave);
1462
+ const hasRecordModeDraft = nextSignature !== savedSignature;
1463
+ const showSave = canEdit && Boolean(onUpdateRunConfig) && hasRecordModeDraft;
1464
+ const canSubmit = showSave && !pending;
1465
+ async function saveRecordMode() {
1466
+ if (!canEdit || !onUpdateRunConfig || pending || !hasRecordModeDraft)
1467
+ return;
1468
+ if (!nextUpdate) {
1469
+ setError(labels.runConfigInvalid);
1470
+ return;
1471
+ }
1472
+ setError(null);
1473
+ try {
1474
+ await onUpdateRunConfig(nextUpdate);
1475
+ setSavedSignature(nextSignature);
1476
+ }
1477
+ catch (saveError) {
1478
+ setError(getApiErrorMessage(saveError) ?? labels.runConfigUpdateFailed);
1479
+ }
1480
+ }
1481
+ return (_jsxs("section", { className: "rounded-lg border bg-card", children: [_jsxs("div", { className: "flex min-h-10 items-center justify-between gap-2 border-b px-3 py-2", children: [_jsx("div", { className: "text-[12px] font-semibold", children: labels.recordModeTitle }), showSave ? (_jsxs(Button, { type: "button", size: "sm", className: "h-7 px-2 text-[11.5px]", onClick: saveRecordMode, disabled: !canSubmit, children: [_jsx(Save, { className: "size-3.5" }), pending ? labels.savePending : labels.save] })) : null] }), _jsxs("div", { className: "space-y-2 p-3", children: [_jsxs("div", { className: "grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-[12px]", children: [_jsx("span", { className: "pt-1.5 text-muted-foreground", children: labels.recordCategories }), _jsx(RecordCategorySelector, { value: normalizedDraftCategories, options: recordCategoryOptions, disabled: !canEdit || pending, labels: labels, onChange: (next) => {
1482
+ setDraftRecordCategories(next);
1483
+ setError(null);
1484
+ } })] }), _jsx(InspectorRowView, { row: { label: labels.createdAt, value: formatDateTimeOrDash(createdAt) } }), _jsx(InspectorRowView, { row: { label: labels.updatedAt, value: formatDateTimeOrDash(updatedAt) } }), error ? _jsx("p", { className: "text-[12px] text-destructive", children: error }) : null] })] }));
1485
+ }
1486
+ function formatPromptNameWithVersion(promptName, versionLabel) {
1487
+ return [promptName, versionLabel].filter((part) => Boolean(part)).join(' - ');
1488
+ }
1489
+ function ReleaseLaneDetailCards({ tone, labels, identityRows, runtimeEditor, metadataCard, routeEditor, }) {
1490
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mt-4 space-y-3", children: [_jsx(InspectorRowsCard, { rows: identityRows, tone: tone, title: labels.laneIdentityTitle }), runtimeEditor, metadataCard] }), routeEditor] }));
1491
+ }
659
1492
  function getOutputConnector(line, nodeId) {
660
1493
  if (!nodeId.startsWith('output-'))
661
1494
  return null;
@@ -670,7 +1503,7 @@ function getOutputScope(line, connectorId, labels) {
670
1503
  return ([inProduction ? labels.productionScope : null, inCanary ? labels.canaryScope : null].filter(Boolean).join(' + ') ||
671
1504
  null);
672
1505
  }
673
- function buildInspectorDetail({ line, selectedNodeId, labels, formatDateTimeOrDash, onUpdateTrafficRatio, trafficRatioPending, onUpdateRunConfig, runConfigPending, models, modelsLoading, onAddCanary, }) {
1506
+ function buildInspectorDetail({ line, selectedNodeId, labels, formatDateTimeOrDash, onUpdateTrafficRatio, trafficRatioPending, onUpdateRunConfig, runConfigPending, onUpdateOutputRoute, outputRoutePending, onUpdateInputRoute, inputRoutePending, models, modelsLoading, outputConnectors, outputConnectorsLoading, onAddCanary, onStopCanary, onPromoteCanary, canaryActionPending, }) {
674
1507
  if (selectedNodeId === 'upstream') {
675
1508
  return {
676
1509
  icon: 'upstream',
@@ -684,6 +1517,7 @@ function buildInspectorDetail({ line, selectedNodeId, labels, formatDateTimeOrDa
684
1517
  { label: labels.direction, value: labels.inputDirection, mono: false },
685
1518
  { label: labels.prompt, value: line.promptName },
686
1519
  ],
1520
+ content: _jsx(UpstreamInputFields, { line: line, labels: labels }),
687
1521
  };
688
1522
  }
689
1523
  if (selectedNodeId === 'input-route') {
@@ -693,94 +1527,79 @@ function buildInspectorDetail({ line, selectedNodeId, labels, formatDateTimeOrDa
693
1527
  tone: 'neutral',
694
1528
  rows: [],
695
1529
  hideSummary: true,
696
- content: (_jsx(TrafficRatioControl, { line: line, labels: labels, onUpdateTrafficRatio: onUpdateTrafficRatio, pending: trafficRatioPending }, `${line.canary?.id ?? 'no-canary'}:${line.canary?.trafficRatio ?? 0}`)),
697
- blocks: [
698
- {
699
- title: labels.fieldMapping,
700
- body: getVariableMappingBody(line, labels.fieldMappingEmpty),
701
- },
702
- {
703
- title: labels.filterRules,
704
- body: getFilterRulesBody(line, labels),
705
- },
706
- ],
1530
+ content: (_jsx(_Fragment, { children: _jsx(TrafficRatioControl, { line: line, labels: labels, onUpdateTrafficRatio: onUpdateTrafficRatio, pending: trafficRatioPending }, `${line.canary?.id ?? 'no-canary'}:${line.canary?.trafficRatio ?? 0}`) })),
707
1531
  };
708
1532
  }
709
1533
  if (selectedNodeId === 'output-route') {
710
1534
  return {
711
1535
  icon: 'router',
712
1536
  label: labels.outputRoute,
713
- title: labels.outputRouteTitle,
714
- subtitle: labels.outputMapping,
715
1537
  tone: 'neutral',
716
- rows: [
717
- {
718
- label: labels.outputScope,
719
- value: line.outputConnectors.map((connector) => connector.name).join(', ') || null,
720
- },
721
- {
722
- label: labels.productionScope,
723
- value: line.production?.outputConnectors?.length ? String(line.production.outputConnectors.length) : '0',
724
- },
725
- {
726
- label: labels.canaryScope,
727
- value: line.canary?.outputConnectors.length ? String(line.canary.outputConnectors.length) : '0',
728
- },
729
- ],
730
- blocks: [
731
- {
732
- title: labels.outputMapping,
733
- body: getOutputMappingBody(line, labels),
734
- },
735
- ],
1538
+ rows: [],
1539
+ hideSummary: true,
1540
+ content: (_jsxs(_Fragment, { children: [_jsx(OutputRouteSummaryCards, { line: line, outputConnectors: outputConnectors, labels: labels }), _jsx(OutputRouteControl, { line: line, outputConnectors: outputConnectors, outputConnectorsLoading: outputConnectorsLoading, labels: labels, pending: outputRoutePending, onUpdateOutputRoute: onUpdateOutputRoute }, `${line.production?.currentEvent?.id ?? 'no-production'}:${line.canary?.id ?? 'no-canary'}`)] })),
736
1541
  };
737
1542
  }
738
1543
  if (selectedNodeId === 'production') {
739
1544
  const event = line.production?.currentEvent ?? null;
1545
+ const inputRouteLane = getInputRouteLane(line, labels, 'production');
1546
+ const productionCanEditConfig = event?.status === 'running';
1547
+ const routeEditor = inputRouteLane ? (_jsx(InputRouteLaneEditor, { lane: inputRouteLane, labels: labels, pending: inputRoutePending, onUpdateInputRoute: onUpdateInputRoute }, `input-route:${inputRouteLane.laneType}:${inputRouteLane.eventId}:${inputRouteLane.updatedAt ?? ''}`)) : undefined;
740
1548
  return {
741
1549
  icon: 'production',
742
1550
  label: labels.production,
743
1551
  title: line.productionVersionLabel ?? labels.noProduction,
744
1552
  subtitle: line.productionModelName ?? labels.noModel,
745
1553
  tone: event ? 'production' : 'muted',
746
- rows: [
747
- { label: labels.prompt, value: line.promptName },
748
- { label: labels.promptVersion, value: line.productionVersionLabel },
749
- { label: labels.model, value: line.productionModelName },
750
- { label: labels.eventId, value: event?.id },
751
- { label: labels.status, value: event?.status ?? line.production?.aggregateStatus },
752
- { label: labels.externalId, value: event?.externalIdField },
753
- { label: labels.recordMode, value: event?.recordMode },
754
- { label: labels.startedAt, value: formatDateTimeOrDash(event?.startedAt) },
755
- { label: labels.updatedAt, value: formatDateTimeOrDash(event?.updatedAt) },
756
- ],
757
- runtimeEditor: event ? (_jsx(RuntimeConfigEditor, { laneType: "production", config: event.runConfig, currentModelId: event.modelId, currentModelName: line.productionModelName, models: models, modelsLoading: modelsLoading, labels: labels, canEdit: event.status === 'running', pending: runConfigPending, onUpdateRunConfig: onUpdateRunConfig }, `production:${event.id}:${event.updatedAt}`)) : undefined,
1554
+ rows: [],
1555
+ hideSummary: true,
1556
+ content: event ? (_jsx(ReleaseLaneDetailCards, { tone: "production", labels: labels, identityRows: [
1557
+ {
1558
+ label: labels.model,
1559
+ valueNode: (_jsx(LinkedInspectorValue, { href: resourceDetailHref('model', event.modelId), value: line.productionModelName })),
1560
+ mono: false,
1561
+ },
1562
+ {
1563
+ label: labels.prompt,
1564
+ valueNode: (_jsx(LinkedInspectorValue, { href: resourceDetailHref('prompt', event.promptId ?? line.promptId, event.promptVersionId), value: formatPromptNameWithVersion(line.promptName, line.productionVersionLabel) })),
1565
+ mono: false,
1566
+ },
1567
+ ], runtimeEditor: _jsx(RuntimeConfigEditor, { laneType: "production", config: event.runConfig, currentModelId: event.modelId, currentModelName: line.productionModelName, recordMode: event.recordMode, models: models, modelsLoading: modelsLoading, labels: labels, canEdit: productionCanEditConfig, pending: runConfigPending, onUpdateRunConfig: onUpdateRunConfig, hideModel: true }, `production:${event.id}:${event.updatedAt}`), metadataCard: _jsx(RecordModeMetadataCard, { laneType: "production", config: event.runConfig, modelId: event.modelId, recordMode: event.recordMode, recordCategories: event.recordCategories, promptVersionSnapshot: event.promptVersionSnapshot, createdAt: event.createdAt, updatedAt: event.updatedAt, labels: labels, formatDateTimeOrDash: formatDateTimeOrDash, canEdit: productionCanEditConfig, pending: runConfigPending, onUpdateRunConfig: onUpdateRunConfig }, `production-meta:${event.id}:${event.recordMode}:${event.recordCategories.join('|')}:${event.updatedAt}`), routeEditor: routeEditor })) : (_jsx(InspectorRowsCard, { className: "mt-4", rows: [{ label: labels.status, value: labels.noProduction }] })),
758
1568
  };
759
1569
  }
760
1570
  if (selectedNodeId === 'canary') {
761
1571
  const canary = line.canary;
762
- const canAddCanary = !canary && line.production?.currentEvent?.status === 'running' && Boolean(onAddCanary);
1572
+ const canCreateOrReplaceCanary = line.production?.currentEvent?.status === 'running' && Boolean(onAddCanary);
1573
+ const canShowAddCanarySlot = !canary && canCreateOrReplaceCanary;
1574
+ const canaryCanEditConfig = isAdjustableCanary(canary);
1575
+ const inputRouteLane = getInputRouteLane(line, labels, 'canary');
1576
+ const routeEditor = inputRouteLane ? (_jsx(InputRouteLaneEditor, { lane: inputRouteLane, labels: labels, pending: inputRoutePending, onUpdateInputRoute: onUpdateInputRoute }, `input-route:${inputRouteLane.laneType}:${inputRouteLane.eventId}:${inputRouteLane.updatedAt ?? ''}`)) : undefined;
763
1577
  return {
764
- icon: canAddCanary ? 'addCanary' : 'canary',
1578
+ icon: canShowAddCanarySlot ? 'addCanary' : canaryTopologyIcon(canary),
765
1579
  label: labels.canary,
766
- title: canAddCanary ? labels.addCanary : (line.canaryVersionLabel ?? labels.noCanary),
1580
+ title: canShowAddCanarySlot ? labels.addCanary : (line.canaryVersionLabel ?? labels.noCanary),
767
1581
  subtitle: line.canaryModelName ?? labels.readyForCandidate,
768
1582
  tone: canary ? 'canary' : 'muted',
769
- rows: [
770
- { label: labels.prompt, value: line.promptName },
771
- { label: labels.promptVersion, value: line.canaryVersionLabel },
772
- { label: labels.model, value: line.canaryModelName },
773
- { label: labels.canaryId, value: canary?.id },
774
- { label: labels.status, value: canary?.status },
775
- { label: labels.trafficRatio, value: canary ? formatPercent(canary.trafficRatio, 0) : labels.noTraffic },
776
- { label: labels.trafficMode, value: canary ? labels.canaryMode(canary.trafficMode) : null },
777
- { label: labels.externalId, value: canary?.externalIdField },
778
- { label: labels.recordMode, value: canary?.recordMode },
779
- { label: labels.startedAt, value: formatDateTimeOrDash(canary?.startedAt) },
780
- { label: labels.updatedAt, value: formatDateTimeOrDash(canary?.updatedAt) },
781
- ],
782
- runtimeEditor: canary ? (_jsx(RuntimeConfigEditor, { laneType: "canary", config: canary.runConfig, currentModelId: canary.modelId, currentModelName: canary.modelName, models: models, modelsLoading: modelsLoading, labels: labels, canEdit: isAdjustableCanary(canary), pending: runConfigPending, onUpdateRunConfig: onUpdateRunConfig }, `canary:${canary.id}:${canary.updatedAt}`)) : undefined,
783
- action: canAddCanary ? (_jsxs(Button, { type: "button", onClick: onAddCanary, children: [_jsx(Plus, { className: "size-4" }), labels.addCanary] })) : undefined,
1583
+ rows: [],
1584
+ hideSummary: true,
1585
+ content: canary ? (_jsx(ReleaseLaneDetailCards, { tone: "canary", labels: labels, identityRows: [
1586
+ {
1587
+ label: labels.model,
1588
+ valueNode: (_jsx(LinkedInspectorValue, { href: resourceDetailHref('model', canary.modelId), value: line.canaryModelName })),
1589
+ mono: false,
1590
+ },
1591
+ {
1592
+ label: labels.prompt,
1593
+ valueNode: (_jsx(LinkedInspectorValue, { href: resourceDetailHref('prompt', canary.promptId ?? line.promptId, canary.promptVersionId), value: formatPromptNameWithVersion(line.promptName, line.canaryVersionLabel) })),
1594
+ mono: false,
1595
+ },
1596
+ {
1597
+ label: labels.termination,
1598
+ value: formatCanaryStopConditions(canary.runConfig, labels),
1599
+ mono: false,
1600
+ },
1601
+ ], runtimeEditor: _jsx(RuntimeConfigEditor, { laneType: "canary", config: canary.runConfig, currentModelId: canary.modelId, currentModelName: canary.modelName, recordMode: canary.recordMode, models: models, modelsLoading: modelsLoading, labels: labels, canEdit: canaryCanEditConfig, pending: runConfigPending, onUpdateRunConfig: onUpdateRunConfig, hideModel: true }, `canary:${canary.id}:${canary.updatedAt}`), metadataCard: _jsx(RecordModeMetadataCard, { laneType: "canary", config: canary.runConfig, modelId: canary.modelId, recordMode: canary.recordMode, recordCategories: canary.recordCategories, promptVersionSnapshot: readRecord(line.canaryPromptVersionSnapshot) ?? EMPTY_RECORD, createdAt: canary.createdAt, updatedAt: canary.updatedAt, labels: labels, formatDateTimeOrDash: formatDateTimeOrDash, canEdit: canaryCanEditConfig, pending: runConfigPending, onUpdateRunConfig: onUpdateRunConfig }, `canary-meta:${canary.id}:${canary.recordMode}:${canary.recordCategories.join('|')}:${canary.updatedAt}`), routeEditor: routeEditor })) : (_jsx(InspectorRowsCard, { className: "mt-4", rows: [{ label: labels.status, value: labels.noCanary }] })),
1602
+ headerAction: canary || canCreateOrReplaceCanary ? (_jsx(CanaryLaneActions, { canary: canary, labels: labels, pending: canaryActionPending || trafficRatioPending, onAddCanary: canCreateOrReplaceCanary ? onAddCanary : undefined, onStopCanary: onStopCanary, onPromoteCanary: onPromoteCanary })) : undefined,
784
1603
  };
785
1604
  }
786
1605
  const connector = getOutputConnector(line, selectedNodeId);
@@ -800,14 +1619,18 @@ function buildInspectorDetail({ line, selectedNodeId, labels, formatDateTimeOrDa
800
1619
  };
801
1620
  }
802
1621
  function InspectorRowView({ row }) {
803
- return (_jsxs("div", { className: "grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-[12px]", children: [_jsx("span", { className: "text-muted-foreground", children: row.label }), _jsx("span", { className: cn('min-w-0 truncate', row.mono !== false && 'font-mono'), title: toDisplayValue(row.value), children: toDisplayValue(row.value) })] }));
1622
+ return (_jsxs("div", { className: "grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-[12px]", children: [_jsx("span", { className: "text-muted-foreground", children: row.label }), row.valueNode ? (_jsx("span", { className: cn('min-w-0', row.mono !== false && 'font-mono'), children: row.valueNode })) : (_jsx("span", { className: cn('min-w-0 truncate', row.mono !== false && 'font-mono'), title: toDisplayValue(row.value), children: toDisplayValue(row.value) }))] }));
1623
+ }
1624
+ function InspectorRowsCard({ rows, tone, title, action, className, }) {
1625
+ const token = tone ? TONE_STYLES[tone] : null;
1626
+ return (_jsxs("div", { className: cn('rounded-lg border bg-card', className), style: token ? { borderColor: token.bd } : undefined, children: [title || action ? (_jsxs("div", { className: "flex min-h-10 items-center justify-between gap-2 border-b px-3 py-2", children: [title ? _jsx("div", { className: "text-[12px] font-semibold", children: title }) : _jsx("span", {}), action] })) : null, _jsx("div", { className: "space-y-2 p-3", children: rows.map((row, index) => (_jsx(InspectorRowView, { row: row }, `${row.label}:${index}`))) })] }));
804
1627
  }
805
1628
  function TopologyInspector({ detail, labels, }) {
806
1629
  const token = TONE_STYLES[detail.tone];
807
1630
  const Icon = NODE_ICONS[detail.icon];
808
- return (_jsxs("aside", { className: "flex min-h-[360px] flex-col bg-background/60 p-4", children: [_jsxs("div", { className: "mb-3 flex items-center justify-between gap-2", children: [_jsx("h3", { className: "text-[14px] font-semibold", children: labels.inspector }), _jsx("span", { className: "text-[11.5px] text-muted-foreground", children: labels.clickHint })] }), !detail.hideSummary ? (_jsx("div", { className: "rounded-lg border bg-card p-3", style: { borderColor: token.bd }, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-md border", style: { background: token.bg, borderColor: token.bd, color: token.dot }, children: _jsx(Icon, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "text-[11.5px] font-medium text-muted-foreground", children: detail.label }), detail.title ? (_jsx("div", { className: "mt-1 truncate font-mono text-[13px] font-semibold", title: detail.title, children: detail.title })) : null, detail.subtitle ? (_jsx("div", { className: "mt-1 truncate text-[12px] text-muted-foreground", title: detail.subtitle, children: detail.subtitle })) : null] })] }) })) : null, detail.content, detail.rows.length ? (_jsx("div", { className: "mt-4 space-y-2 rounded-lg border bg-card p-3", children: detail.rows.map((row) => (_jsx(InspectorRowView, { row: row }, `${detail.label}:${row.label}`))) })) : null, detail.runtimeEditor, detail.blocks?.length ? (_jsx("div", { className: "mt-4 space-y-3", children: detail.blocks.map((block) => (_jsxs("section", { className: "rounded-lg border bg-card", children: [_jsx("div", { className: "border-b px-3 py-2 text-[12px] font-semibold", children: block.title }), _jsx("pre", { className: "max-h-36 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-[11.5px] leading-5 text-muted-foreground", children: block.body })] }, `${detail.label}:${block.title}`))) })) : null, detail.action ? _jsx("div", { className: "mt-4 flex justify-end", children: detail.action }) : null] }));
1631
+ return (_jsxs("aside", { className: "flex h-[500px] min-h-[360px] max-h-[calc(100vh-180px)] flex-col overflow-hidden bg-background/60 p-4", children: [_jsxs("div", { className: "mb-3 flex min-h-7 shrink-0 items-start justify-between gap-3", children: [_jsx("h3", { className: "shrink-0 text-[14px] font-semibold", children: labels.inspector }), detail.headerAction] }), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto pr-1", children: [!detail.hideSummary ? (_jsx("div", { className: "rounded-lg border bg-card p-3", style: { borderColor: token.bd }, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-md border", style: { background: token.bg, borderColor: token.bd, color: token.dot }, children: _jsx(Icon, { className: "size-4" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "text-[11.5px] font-medium text-muted-foreground", children: detail.label }), detail.title ? (_jsx("div", { className: "mt-1 truncate font-mono text-[13px] font-semibold", title: detail.title, children: detail.title })) : null, detail.subtitle ? (_jsx("div", { className: "mt-1 truncate text-[12px] text-muted-foreground", title: detail.subtitle, children: detail.subtitle })) : null] })] }) })) : null, detail.rows.length ? _jsx(InspectorRowsCard, { rows: detail.rows, className: "mt-4" }) : null, detail.content, detail.runtimeEditor, detail.blocks?.length ? (_jsx("div", { className: "mt-4 space-y-3", children: detail.blocks.map((block) => (_jsxs("section", { className: "rounded-lg border bg-card", children: [_jsx("div", { className: "border-b px-3 py-2 text-[12px] font-semibold", children: block.title }), _jsx("pre", { className: "max-h-36 overflow-auto whitespace-pre-wrap break-words p-3 font-mono text-[11.5px] leading-5 text-muted-foreground", children: block.body })] }, `${detail.label}:${block.title}`))) })) : null, detail.action ? _jsx("div", { className: "mt-4 flex justify-end", children: detail.action }) : null] })] }));
809
1632
  }
810
- export function ReleaseTopologyCanvas({ line, models = [], modelsLoading = false, onUpdateTrafficRatio, onUpdateRunConfig, onAddCanary, trafficRatioPending = false, runConfigPending = false, }) {
1633
+ export function ReleaseTopologyCanvas({ line, models = [], modelsLoading = false, outputConnectors = [], outputConnectorsLoading = false, onUpdateTrafficRatio, onUpdateRunConfig, onUpdateOutputRoute, onUpdateInputRoute, onAddCanary, onStopCanary, onPromoteCanary, trafficRatioPending = false, runConfigPending = false, outputRoutePending = false, inputRoutePending = false, canaryActionPending = false, }) {
811
1634
  const labels = useTopologyLabels(line);
812
1635
  const { formatDateTime } = useDateTimeFormatter();
813
1636
  const formatDateTimeOrDash = useMemo(() => (value) => (value ? formatDateTime(value, { fallback: '—' }) : '—'), [formatDateTime]);
@@ -829,23 +1652,41 @@ export function ReleaseTopologyCanvas({ line, models = [], modelsLoading = false
829
1652
  trafficRatioPending,
830
1653
  onUpdateRunConfig,
831
1654
  runConfigPending,
1655
+ onUpdateOutputRoute,
1656
+ outputRoutePending,
1657
+ onUpdateInputRoute,
1658
+ inputRoutePending,
832
1659
  models,
833
1660
  modelsLoading,
1661
+ outputConnectors,
1662
+ outputConnectorsLoading,
834
1663
  onAddCanary,
1664
+ onStopCanary,
1665
+ onPromoteCanary,
1666
+ canaryActionPending,
835
1667
  }), [
1668
+ canaryActionPending,
836
1669
  labels,
837
1670
  formatDateTimeOrDash,
838
1671
  line,
839
1672
  onAddCanary,
1673
+ onPromoteCanary,
1674
+ onStopCanary,
840
1675
  onUpdateRunConfig,
841
1676
  onUpdateTrafficRatio,
842
1677
  models,
843
1678
  modelsLoading,
844
1679
  runConfigPending,
1680
+ onUpdateOutputRoute,
1681
+ onUpdateInputRoute,
1682
+ outputConnectors,
1683
+ outputConnectorsLoading,
1684
+ outputRoutePending,
1685
+ inputRoutePending,
845
1686
  selectedNodeId,
846
1687
  trafficRatioPending,
847
1688
  ]);
848
- return (_jsxs("div", { className: "release-topology-canvas rounded-lg border bg-card", "data-testid": "release-topology-canvas", children: [_jsx("div", { className: "border-b px-4 py-3", children: _jsx("h2", { className: "text-[14px] font-semibold", children: labels.routeMeta }) }), _jsxs("div", { className: "grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_380px]", children: [_jsx("div", { className: "h-[500px] min-h-[380px] w-full border-b xl:border-b-0 xl:border-r", children: _jsxs(ReactFlow, { nodes: nodes, edges: topology.edges, nodeTypes: nodeTypes, nodesDraggable: false, nodesConnectable: false, elementsSelectable: false, onNodeClick: (_, node) => {
1689
+ return (_jsxs("div", { className: "release-topology-canvas rounded-lg border bg-card", "data-testid": "release-topology-canvas", children: [_jsx("div", { className: "border-b px-4 py-3", children: _jsx("h2", { className: "text-[14px] font-semibold", children: labels.routeMeta }) }), _jsxs("div", { className: "grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_380px]", children: [_jsx("div", { className: "h-[500px] min-h-[380px] w-full border-b xl:border-b-0 xl:border-r", children: _jsxs(ReactFlow, { nodes: nodes, edges: topology.edges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, nodesDraggable: false, nodesConnectable: false, elementsSelectable: false, onNodeClick: (_, node) => {
849
1690
  if (node.data.action === 'addCanary' && onAddCanary) {
850
1691
  onAddCanary();
851
1692
  return;