@ryanfw/prompt-orchestration-pipeline 0.6.0 → 0.7.0
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.
- package/README.md +1 -2
- package/package.json +1 -2
- package/src/api/validators/json.js +39 -0
- package/src/components/DAGGrid.jsx +392 -303
- package/src/components/JobCard.jsx +13 -11
- package/src/components/JobDetail.jsx +41 -71
- package/src/components/JobTable.jsx +32 -22
- package/src/components/Layout.jsx +0 -21
- package/src/components/LiveText.jsx +47 -0
- package/src/components/TaskDetailSidebar.jsx +216 -0
- package/src/components/TimerText.jsx +82 -0
- package/src/components/ui/RestartJobModal.jsx +140 -0
- package/src/components/ui/toast.jsx +138 -0
- package/src/config/models.js +322 -0
- package/src/config/statuses.js +119 -0
- package/src/core/config.js +2 -164
- package/src/core/file-io.js +1 -1
- package/src/core/module-loader.js +54 -40
- package/src/core/pipeline-runner.js +52 -20
- package/src/core/status-writer.js +147 -3
- package/src/core/symlink-bridge.js +57 -0
- package/src/core/symlink-utils.js +94 -0
- package/src/core/task-runner.js +267 -443
- package/src/llm/index.js +167 -52
- package/src/pages/Code.jsx +57 -3
- package/src/pages/PipelineDetail.jsx +92 -22
- package/src/pages/PromptPipelineDashboard.jsx +15 -36
- package/src/providers/anthropic.js +83 -69
- package/src/providers/base.js +52 -0
- package/src/providers/deepseek.js +17 -34
- package/src/providers/gemini.js +226 -0
- package/src/providers/openai.js +36 -106
- package/src/providers/zhipu.js +136 -0
- package/src/ui/client/adapters/job-adapter.js +16 -26
- package/src/ui/client/api.js +134 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -178
- package/src/ui/client/index.css +9 -0
- package/src/ui/client/index.html +1 -0
- package/src/ui/client/main.jsx +18 -15
- package/src/ui/client/time-store.js +161 -0
- package/src/ui/config-bridge.js +15 -24
- package/src/ui/config-bridge.node.js +15 -24
- package/src/ui/dist/assets/{index-WgJUlSmE.js → index-DqkbzXZ1.js} +1408 -771
- package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
- package/src/ui/dist/index.html +3 -2
- package/src/ui/public/favicon.svg +12 -0
- package/src/ui/server.js +231 -33
- package/src/ui/transformers/status-transformer.js +18 -31
- package/src/utils/dag.js +8 -4
- package/src/utils/duration.js +13 -19
- package/src/utils/formatters.js +27 -0
- package/src/utils/geometry-equality.js +83 -0
- package/src/utils/pipelines.js +5 -1
- package/src/utils/time-utils.js +40 -0
- package/src/utils/token-cost-calculator.js +4 -7
- package/src/utils/ui.jsx +14 -16
- package/src/components/ui/select.jsx +0 -27
- package/src/lib/utils.js +0 -6
- package/src/ui/client/hooks/useTicker.js +0 -26
- package/src/ui/config-bridge.browser.js +0 -149
- package/src/ui/dist/assets/style-x0V-5m8e.css +0 -62
|
@@ -4,10 +4,23 @@ import React, {
|
|
|
4
4
|
useRef,
|
|
5
5
|
useState,
|
|
6
6
|
createRef,
|
|
7
|
+
memo,
|
|
7
8
|
} from "react";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { areGeometriesEqual } from "../utils/geometry-equality.js";
|
|
10
|
+
import { TaskDetailSidebar } from "./TaskDetailSidebar.jsx";
|
|
11
|
+
import { RestartJobModal } from "./ui/RestartJobModal.jsx";
|
|
12
|
+
import { Button } from "./ui/button.jsx";
|
|
13
|
+
import { restartJob } from "../ui/client/api.js";
|
|
10
14
|
import { createEmptyTaskFiles } from "../utils/task-files.js";
|
|
15
|
+
import { TaskState } from "../config/statuses.js";
|
|
16
|
+
import TimerText from "./TimerText.jsx";
|
|
17
|
+
import { taskToTimerProps } from "../utils/time-utils.js";
|
|
18
|
+
|
|
19
|
+
// Utility to check for reduced motion preference
|
|
20
|
+
const prefersReducedMotion = () => {
|
|
21
|
+
if (typeof window === "undefined") return false;
|
|
22
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
23
|
+
};
|
|
11
24
|
|
|
12
25
|
// Helpers: capitalize fallback step ids (upperFirst only; do not alter provided titles)
|
|
13
26
|
function upperFirst(s) {
|
|
@@ -51,41 +64,156 @@ function formatStageLabel(s) {
|
|
|
51
64
|
|
|
52
65
|
function formatStepName(item, idx) {
|
|
53
66
|
const raw = item.title ?? item.id ?? `Step ${idx + 1}`;
|
|
54
|
-
// If item has a title, assume it
|
|
67
|
+
// If item has a title, assume it's curated and leave unchanged; otherwise capitalize fallback
|
|
55
68
|
return upperFirst(item.title ? item.title : raw);
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
// Get CSS classes for card header based on status
|
|
72
|
+
const getHeaderClasses = (status) => {
|
|
73
|
+
switch (status) {
|
|
74
|
+
case TaskState.DONE:
|
|
75
|
+
return "bg-green-50 border-green-200 text-green-700";
|
|
76
|
+
case TaskState.RUNNING:
|
|
77
|
+
return "bg-amber-50 border-amber-200 text-amber-700";
|
|
78
|
+
case TaskState.FAILED:
|
|
79
|
+
return "bg-pink-50 border-pink-200 text-pink-700";
|
|
80
|
+
default:
|
|
81
|
+
return "bg-gray-100 border-gray-200 text-gray-700";
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Check if Restart button should be shown for a given status
|
|
86
|
+
const canShowRestart = (status) => {
|
|
87
|
+
return status === TaskState.FAILED || status === TaskState.DONE;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Memoized card component to prevent unnecessary re-renders
|
|
91
|
+
const TaskCard = memo(function TaskCard({
|
|
92
|
+
item,
|
|
93
|
+
idx,
|
|
94
|
+
nodeRef,
|
|
95
|
+
status,
|
|
96
|
+
isActive,
|
|
97
|
+
canRestart,
|
|
98
|
+
isSubmitting,
|
|
99
|
+
getRestartDisabledReason,
|
|
100
|
+
onClick,
|
|
101
|
+
onKeyDown,
|
|
102
|
+
handleRestartClick,
|
|
103
|
+
}) {
|
|
104
|
+
const { startMs, endMs } = taskToTimerProps(item);
|
|
105
|
+
const reducedMotion = prefersReducedMotion();
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
ref={nodeRef}
|
|
110
|
+
role="listitem"
|
|
111
|
+
aria-current={isActive ? "step" : undefined}
|
|
112
|
+
tabIndex={0}
|
|
113
|
+
onClick={onClick}
|
|
114
|
+
onKeyDown={onKeyDown}
|
|
115
|
+
className={`cursor-pointer rounded-lg border border-gray-400 ${status === TaskState.PENDING ? "bg-gray-50" : "bg-white"} overflow-hidden flex flex-col ${reducedMotion ? "" : "transition-all duration-200 ease-in-out"} outline outline-2 outline-transparent hover:outline-gray-400/70 focus-visible:outline-blue-500/60`}
|
|
116
|
+
>
|
|
117
|
+
<div
|
|
118
|
+
data-role="card-header"
|
|
119
|
+
className={`rounded-t-lg px-4 py-2 border-b flex items-center justify-between gap-3 ${reducedMotion ? "" : "transition-opacity duration-300 ease-in-out"} ${getHeaderClasses(status)}`}
|
|
120
|
+
>
|
|
121
|
+
<div className="font-medium truncate">{formatStepName(item, idx)}</div>
|
|
122
|
+
<div className="flex items-center gap-2">
|
|
123
|
+
{status === TaskState.RUNNING ? (
|
|
124
|
+
<>
|
|
125
|
+
<div className="relative h-4 w-4" aria-label="Active">
|
|
126
|
+
<span className="sr-only">Active</span>
|
|
127
|
+
<span className="absolute inset-0 rounded-full border-2 border-amber-200" />
|
|
128
|
+
<span
|
|
129
|
+
className={`absolute inset-0 rounded-full border-2 border-transparent border-t-amber-600 ${reducedMotion ? "" : "animate-spin"}`}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
{item.stage && (
|
|
133
|
+
<span
|
|
134
|
+
className="text-[11px] font-medium opacity-80 truncate uppercase tracking-wide"
|
|
135
|
+
title={item.stage}
|
|
136
|
+
>
|
|
137
|
+
{formatStageLabel(item.stage)}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
{startMs && (
|
|
141
|
+
<TimerText
|
|
142
|
+
startMs={startMs}
|
|
143
|
+
granularity="second"
|
|
144
|
+
className="text-[11px] opacity-80"
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
</>
|
|
148
|
+
) : (
|
|
149
|
+
<>
|
|
150
|
+
<span
|
|
151
|
+
className={`text-[11px] uppercase tracking-wide opacity-80${reducedMotion ? "" : " transition-opacity duration-200"}`}
|
|
152
|
+
>
|
|
153
|
+
{status}
|
|
154
|
+
{status === TaskState.FAILED && item.stage && (
|
|
155
|
+
<span
|
|
156
|
+
className="text-[11px] font-medium opacity-80 truncate ml-2 uppercase tracking-wide"
|
|
157
|
+
title={item.stage}
|
|
158
|
+
>
|
|
159
|
+
({formatStageLabel(item.stage)})
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</span>
|
|
163
|
+
{status === TaskState.DONE && startMs && (
|
|
164
|
+
<TimerText
|
|
165
|
+
startMs={startMs}
|
|
166
|
+
endMs={endMs || item.finishedAt}
|
|
167
|
+
granularity="minute"
|
|
168
|
+
className="text-[11px] opacity-80"
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="p-4">
|
|
176
|
+
{item.subtitle && (
|
|
177
|
+
<div className="text-sm text-gray-600">{item.subtitle}</div>
|
|
178
|
+
)}
|
|
179
|
+
{item.body && (
|
|
180
|
+
<div className="mt-2 text-sm text-gray-700">{item.body}</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{/* Restart button */}
|
|
184
|
+
{canShowRestart(status) && (
|
|
185
|
+
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
186
|
+
<Button
|
|
187
|
+
variant="outline"
|
|
188
|
+
size="sm"
|
|
189
|
+
onClick={(e) => handleRestartClick(e, item.id)}
|
|
190
|
+
disabled={!canRestart || isSubmitting}
|
|
191
|
+
className="text-xs cursor-pointer disabled:cursor-not-allowed"
|
|
192
|
+
title={
|
|
193
|
+
!canRestart
|
|
194
|
+
? getRestartDisabledReason()
|
|
195
|
+
: `Restart job from ${item.id}`
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
Restart
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
58
207
|
/**
|
|
59
208
|
* DAGGrid component for visualizing pipeline tasks with connectors and slide-over details
|
|
60
209
|
* @param {Object} props
|
|
61
210
|
* @param {Array} props.items - Array of DAG items with id, status, and optional title/subtitle
|
|
62
211
|
* @param {number} props.cols - Number of columns for grid layout (default: 3)
|
|
63
212
|
* @param {string} props.cardClass - Additional CSS classes for cards
|
|
64
|
-
* @param {number} props.activeIndex - Index of
|
|
213
|
+
* @param {number} props.activeIndex - Index of active item
|
|
65
214
|
* @param {string} props.jobId - Job ID for file operations
|
|
66
215
|
* @param {Function} props.filesByTypeForItem - Selector returning { artifacts, logs, tmp }
|
|
67
216
|
*/
|
|
68
|
-
// Instrumentation helper for DAGGrid
|
|
69
|
-
const createDAGGridLogger = (jobId) => {
|
|
70
|
-
const prefix = `[DAGGrid:${jobId || "unknown"}]`;
|
|
71
|
-
return {
|
|
72
|
-
log: (message, data = null) => {
|
|
73
|
-
console.log(`${prefix} ${message}`, data ? data : "");
|
|
74
|
-
},
|
|
75
|
-
warn: (message, data = null) => {
|
|
76
|
-
console.warn(`${prefix} ${message}`, data ? data : "");
|
|
77
|
-
},
|
|
78
|
-
error: (message, data = null) => {
|
|
79
|
-
console.error(`${prefix} ${message}`, data ? data : "");
|
|
80
|
-
},
|
|
81
|
-
group: (label) => console.group(`${prefix} ${label}`),
|
|
82
|
-
groupEnd: () => console.groupEnd(),
|
|
83
|
-
table: (data, title) => {
|
|
84
|
-
console.log(`${prefix} ${title}:`);
|
|
85
|
-
console.table(data);
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
};
|
|
89
217
|
|
|
90
218
|
function DAGGrid({
|
|
91
219
|
items,
|
|
@@ -95,31 +223,23 @@ function DAGGrid({
|
|
|
95
223
|
jobId,
|
|
96
224
|
filesByTypeForItem = () => createEmptyTaskFiles(),
|
|
97
225
|
}) {
|
|
98
|
-
const logger = React.useMemo(() => createDAGGridLogger(jobId), [jobId]);
|
|
99
|
-
|
|
100
226
|
const overlayRef = useRef(null);
|
|
101
227
|
const gridRef = useRef(null);
|
|
102
228
|
const nodeRefs = useRef([]);
|
|
103
229
|
const [lines, setLines] = useState([]);
|
|
104
230
|
const [effectiveCols, setEffectiveCols] = useState(cols);
|
|
105
|
-
const [openIdx, setOpenIdx] = useState(
|
|
106
|
-
const [selectedFile, setSelectedFile] = useState(null);
|
|
107
|
-
const [filePaneOpen, setFilePaneOpen] = useState(false);
|
|
108
|
-
const [filePaneType, setFilePaneType] = useState("artifacts");
|
|
109
|
-
const [filePaneFilename, setFilePaneFilename] = useState(null);
|
|
231
|
+
const [openIdx, setOpenIdx] = useState(-1);
|
|
110
232
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
logger.groupEnd();
|
|
122
|
-
}, [items, cols, activeIndex, jobId, logger]);
|
|
233
|
+
// Restart modal state
|
|
234
|
+
const [restartModalOpen, setRestartModalOpen] = useState(false);
|
|
235
|
+
const [restartTaskId, setRestartTaskId] = useState(null);
|
|
236
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
237
|
+
const [alertMessage, setAlertMessage] = useState(null);
|
|
238
|
+
const [alertType, setAlertType] = useState("info"); // info, success, error, warning
|
|
239
|
+
|
|
240
|
+
// Previous geometry snapshot for throttling connector recomputation
|
|
241
|
+
const prevGeometryRef = useRef(null);
|
|
242
|
+
const rafRef = useRef(null);
|
|
123
243
|
|
|
124
244
|
// Create refs for each node
|
|
125
245
|
nodeRefs.current = useMemo(
|
|
@@ -165,11 +285,12 @@ function DAGGrid({
|
|
|
165
285
|
const end = Math.min(start + effectiveCols, items.length);
|
|
166
286
|
const slice = Array.from({ length: end - start }, (_, k) => start + k);
|
|
167
287
|
const rowLen = slice.length;
|
|
168
|
-
const pad = Math.max(0, effectiveCols - rowLen);
|
|
169
288
|
|
|
170
|
-
|
|
171
|
-
|
|
289
|
+
const isReversedRow = r % 2 === 1; // odd rows RTL
|
|
290
|
+
if (isReversedRow) {
|
|
291
|
+
// Reverse order for even rows (snake pattern)
|
|
172
292
|
const reversed = slice.reverse();
|
|
293
|
+
const pad = effectiveCols - rowLen;
|
|
173
294
|
order.push(...Array(pad).fill(-1), ...reversed);
|
|
174
295
|
} else {
|
|
175
296
|
order.push(...slice);
|
|
@@ -179,7 +300,7 @@ function DAGGrid({
|
|
|
179
300
|
return order;
|
|
180
301
|
}, [items.length, effectiveCols]);
|
|
181
302
|
|
|
182
|
-
// Calculate connector lines between cards
|
|
303
|
+
// Calculate connector lines between cards with throttling
|
|
183
304
|
useLayoutEffect(() => {
|
|
184
305
|
// Skip entirely in test environment to prevent hanging
|
|
185
306
|
if (process.env.NODE_ENV === "test") {
|
|
@@ -195,12 +316,14 @@ function DAGGrid({
|
|
|
195
316
|
return;
|
|
196
317
|
}
|
|
197
318
|
|
|
198
|
-
|
|
319
|
+
// Throttled compute function using requestAnimationFrame
|
|
199
320
|
const compute = () => {
|
|
200
|
-
|
|
201
|
-
|
|
321
|
+
// Cancel any pending RAF
|
|
322
|
+
if (rafRef.current) {
|
|
323
|
+
cancelAnimationFrame(rafRef.current);
|
|
324
|
+
}
|
|
202
325
|
|
|
203
|
-
|
|
326
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
204
327
|
if (!overlayRef.current) return;
|
|
205
328
|
|
|
206
329
|
const overlayBox = overlayRef.current.getBoundingClientRect();
|
|
@@ -228,6 +351,23 @@ function DAGGrid({
|
|
|
228
351
|
};
|
|
229
352
|
});
|
|
230
353
|
|
|
354
|
+
// Check if geometry changed significantly
|
|
355
|
+
const currentGeometry = {
|
|
356
|
+
overlayBox,
|
|
357
|
+
boxes: boxes.filter(Boolean),
|
|
358
|
+
effectiveCols,
|
|
359
|
+
itemsLength: items.length,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const geometryChanged =
|
|
363
|
+
!prevGeometryRef.current ||
|
|
364
|
+
!areGeometriesEqual(prevGeometryRef.current, currentGeometry);
|
|
365
|
+
|
|
366
|
+
if (!geometryChanged) {
|
|
367
|
+
rafRef.current = null;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
231
371
|
const newLines = [];
|
|
232
372
|
for (let i = 0; i < items.length - 1; i++) {
|
|
233
373
|
const a = boxes[i];
|
|
@@ -267,10 +407,10 @@ function DAGGrid({
|
|
|
267
407
|
}
|
|
268
408
|
}
|
|
269
409
|
|
|
410
|
+
prevGeometryRef.current = currentGeometry;
|
|
270
411
|
setLines(newLines);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
412
|
+
rafRef.current = null;
|
|
413
|
+
});
|
|
274
414
|
};
|
|
275
415
|
|
|
276
416
|
// Initial compute
|
|
@@ -297,6 +437,9 @@ function DAGGrid({
|
|
|
297
437
|
if (ro) ro.disconnect();
|
|
298
438
|
window.removeEventListener("resize", handleResize);
|
|
299
439
|
window.removeEventListener("scroll", handleScroll, true);
|
|
440
|
+
if (rafRef.current) {
|
|
441
|
+
cancelAnimationFrame(rafRef.current);
|
|
442
|
+
}
|
|
300
443
|
};
|
|
301
444
|
}, [items, effectiveCols, visualOrder]);
|
|
302
445
|
|
|
@@ -304,72 +447,181 @@ function DAGGrid({
|
|
|
304
447
|
const getStatus = (index) => {
|
|
305
448
|
const item = items[index];
|
|
306
449
|
const s = item?.status;
|
|
307
|
-
if (s ===
|
|
308
|
-
if (s ===
|
|
309
|
-
if (s ===
|
|
450
|
+
if (s === TaskState.FAILED) return TaskState.FAILED;
|
|
451
|
+
if (s === TaskState.DONE) return TaskState.DONE;
|
|
452
|
+
if (s === TaskState.RUNNING) return TaskState.RUNNING;
|
|
310
453
|
if (typeof activeIndex === "number") {
|
|
311
|
-
if (index < activeIndex) return
|
|
312
|
-
if (index === activeIndex) return
|
|
313
|
-
return
|
|
314
|
-
}
|
|
315
|
-
return "pending";
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
// Get CSS classes for card header based on status
|
|
319
|
-
const getHeaderClasses = (status) => {
|
|
320
|
-
switch (status) {
|
|
321
|
-
case "done":
|
|
322
|
-
return "bg-green-50 border-green-200 text-green-700";
|
|
323
|
-
case "running":
|
|
324
|
-
return "bg-amber-50 border-amber-200 text-amber-700";
|
|
325
|
-
case "failed":
|
|
326
|
-
return "bg-pink-50 border-pink-200 text-pink-700";
|
|
327
|
-
default:
|
|
328
|
-
return "bg-gray-100 border-gray-200 text-gray-700";
|
|
454
|
+
if (index < activeIndex) return TaskState.DONE;
|
|
455
|
+
if (index === activeIndex) return TaskState.RUNNING;
|
|
456
|
+
return TaskState.PENDING;
|
|
329
457
|
}
|
|
458
|
+
return TaskState.PENDING;
|
|
330
459
|
};
|
|
331
460
|
|
|
332
461
|
// Handle Escape key to close slide-over
|
|
333
462
|
React.useEffect(() => {
|
|
334
463
|
const handleKeyDown = (e) => {
|
|
335
|
-
if (e.key === "Escape" && openIdx !==
|
|
336
|
-
setOpenIdx(
|
|
337
|
-
setSelectedFile(null);
|
|
464
|
+
if (e.key === "Escape" && openIdx !== -1) {
|
|
465
|
+
setOpenIdx(-1);
|
|
338
466
|
}
|
|
339
467
|
};
|
|
340
468
|
|
|
341
|
-
if (openIdx !==
|
|
469
|
+
if (openIdx !== -1) {
|
|
342
470
|
document.addEventListener("keydown", handleKeyDown);
|
|
343
471
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
344
472
|
}
|
|
345
473
|
}, [openIdx]);
|
|
346
474
|
|
|
347
|
-
//
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
475
|
+
// Restart functionality
|
|
476
|
+
const handleRestartClick = (e, taskId) => {
|
|
477
|
+
e.stopPropagation(); // Prevent card click
|
|
478
|
+
setRestartTaskId(taskId);
|
|
479
|
+
setRestartModalOpen(true);
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const handleRestartConfirm = async () => {
|
|
483
|
+
if (!jobId || isSubmitting) return;
|
|
484
|
+
|
|
485
|
+
setIsSubmitting(true);
|
|
486
|
+
setAlertMessage(null);
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const restartOptions = {};
|
|
490
|
+
if (restartTaskId) {
|
|
491
|
+
restartOptions.fromTask = restartTaskId;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
await restartJob(jobId, restartOptions);
|
|
495
|
+
|
|
496
|
+
const successMessage = restartTaskId
|
|
497
|
+
? `Restart requested from ${restartTaskId}. The job will start from that task in the background.`
|
|
498
|
+
: "Restart requested. The job will reset to pending and start in the background.";
|
|
499
|
+
setAlertMessage(successMessage);
|
|
500
|
+
setAlertType("success");
|
|
501
|
+
setRestartModalOpen(false);
|
|
502
|
+
setRestartTaskId(null);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
let message = "Failed to start restart. Try again.";
|
|
505
|
+
let type = "error";
|
|
506
|
+
|
|
507
|
+
switch (error.code) {
|
|
508
|
+
case "job_running":
|
|
509
|
+
message = "Job is currently running; restart is unavailable.";
|
|
510
|
+
type = "warning";
|
|
511
|
+
break;
|
|
512
|
+
case "unsupported_lifecycle":
|
|
513
|
+
message = "Job must be in current lifecycle to restart.";
|
|
514
|
+
type = "warning";
|
|
515
|
+
break;
|
|
516
|
+
case "job_not_found":
|
|
517
|
+
message = "Job not found.";
|
|
518
|
+
type = "error";
|
|
519
|
+
break;
|
|
520
|
+
case "spawn_failed":
|
|
521
|
+
message = "Failed to start restart. Try again.";
|
|
522
|
+
type = "error";
|
|
523
|
+
break;
|
|
524
|
+
default:
|
|
525
|
+
message = error.message || "An unexpected error occurred.";
|
|
526
|
+
type = "error";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
setAlertMessage(message);
|
|
530
|
+
setAlertType(type);
|
|
531
|
+
} finally {
|
|
532
|
+
setIsSubmitting(false);
|
|
352
533
|
}
|
|
353
|
-
}
|
|
534
|
+
};
|
|
354
535
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
536
|
+
const handleRestartCancel = () => {
|
|
537
|
+
setRestartModalOpen(false);
|
|
538
|
+
setRestartTaskId(null);
|
|
539
|
+
};
|
|
359
540
|
|
|
541
|
+
// Clear alert after 5 seconds
|
|
360
542
|
React.useEffect(() => {
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
543
|
+
if (alertMessage) {
|
|
544
|
+
const timer = setTimeout(() => {
|
|
545
|
+
setAlertMessage(null);
|
|
546
|
+
}, 5000);
|
|
547
|
+
return () => clearTimeout(timer);
|
|
365
548
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
549
|
+
}, [alertMessage]);
|
|
550
|
+
|
|
551
|
+
// Check if restart should be enabled (job lifecycle = current and not running)
|
|
552
|
+
const isRestartEnabled = React.useCallback(() => {
|
|
553
|
+
// Check if any item indicates that job is running (job-level state)
|
|
554
|
+
const isJobRunning = items.some(
|
|
555
|
+
(item) => item?.state === TaskState.RUNNING
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
// Check if any task has explicit running status (not derived from activeIndex)
|
|
559
|
+
const hasRunningTask = items.some(
|
|
560
|
+
(item) => item?.status === TaskState.RUNNING
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
const jobLifecycle = items[0]?.lifecycle || "current";
|
|
564
|
+
|
|
565
|
+
return jobLifecycle === "current" && !isJobRunning && !hasRunningTask;
|
|
566
|
+
}, [items]);
|
|
567
|
+
|
|
568
|
+
// Get disabled reason for tooltip
|
|
569
|
+
const getRestartDisabledReason = React.useCallback(() => {
|
|
570
|
+
// Check if any item indicates that job is running (job-level state)
|
|
571
|
+
const isJobRunning = items.some(
|
|
572
|
+
(item) => item?.state === TaskState.RUNNING
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
// Check if any task has explicit running status (not derived from activeIndex)
|
|
576
|
+
const hasRunningTask = items.some(
|
|
577
|
+
(item) => item?.status === TaskState.RUNNING
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
const jobLifecycle = items[0]?.lifecycle || "current";
|
|
581
|
+
|
|
582
|
+
if (isJobRunning || hasRunningTask) return "Job is currently running";
|
|
583
|
+
if (jobLifecycle !== "current") return "Job must be in current lifecycle";
|
|
584
|
+
return "";
|
|
585
|
+
}, [items]);
|
|
370
586
|
|
|
371
587
|
return (
|
|
372
588
|
<div className="relative w-full" role="list">
|
|
589
|
+
{/* Alert notification */}
|
|
590
|
+
{alertMessage && (
|
|
591
|
+
<div
|
|
592
|
+
className={`fixed top-4 right-4 z-[3000] max-w-sm p-4 rounded-lg shadow-lg border ${
|
|
593
|
+
alertType === "success"
|
|
594
|
+
? "bg-green-50 border-green-200 text-green-800"
|
|
595
|
+
: alertType === "error"
|
|
596
|
+
? "bg-red-50 border-red-200 text-red-800"
|
|
597
|
+
: alertType === "warning"
|
|
598
|
+
? "bg-yellow-50 border-yellow-200 text-yellow-800"
|
|
599
|
+
: "bg-blue-50 border-blue-200 text-blue-800"
|
|
600
|
+
}`}
|
|
601
|
+
role="alert"
|
|
602
|
+
aria-live="polite"
|
|
603
|
+
>
|
|
604
|
+
<div className="flex items-start">
|
|
605
|
+
<div className="flex-1">
|
|
606
|
+
<p className="text-sm font-medium">{alertMessage}</p>
|
|
607
|
+
</div>
|
|
608
|
+
<button
|
|
609
|
+
onClick={() => setAlertMessage(null)}
|
|
610
|
+
className="ml-3 flex-shrink-0 inline-flex text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
611
|
+
aria-label="Dismiss notification"
|
|
612
|
+
>
|
|
613
|
+
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
614
|
+
<path
|
|
615
|
+
fillRule="evenodd"
|
|
616
|
+
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
617
|
+
clipRule="evenodd"
|
|
618
|
+
/>
|
|
619
|
+
</svg>
|
|
620
|
+
</button>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
|
|
373
625
|
{/* SVG overlay for connector lines */}
|
|
374
626
|
<svg
|
|
375
627
|
ref={overlayRef}
|
|
@@ -386,8 +638,9 @@ function DAGGrid({
|
|
|
386
638
|
markerHeight="8"
|
|
387
639
|
orient="auto"
|
|
388
640
|
markerUnits="userSpaceOnUse"
|
|
641
|
+
className="text-gray-400"
|
|
389
642
|
>
|
|
390
|
-
<path d="M 0 0 L 10 5 L 0 10 z"
|
|
643
|
+
<path d="M 0 0 L 10 5 L 0 10 z" />
|
|
391
644
|
</marker>
|
|
392
645
|
</defs>
|
|
393
646
|
{lines.map((line, idx) => (
|
|
@@ -396,9 +649,9 @@ function DAGGrid({
|
|
|
396
649
|
d={line.d}
|
|
397
650
|
fill="none"
|
|
398
651
|
stroke="currentColor"
|
|
399
|
-
strokeWidth="
|
|
400
|
-
strokeLinecap="
|
|
401
|
-
className="text-gray-
|
|
652
|
+
strokeWidth="1"
|
|
653
|
+
strokeLinecap="square"
|
|
654
|
+
className="text-gray-400"
|
|
402
655
|
strokeLinejoin="round"
|
|
403
656
|
markerEnd="url(#arrow)"
|
|
404
657
|
/>
|
|
@@ -425,223 +678,59 @@ function DAGGrid({
|
|
|
425
678
|
const item = items[idx];
|
|
426
679
|
const status = getStatus(idx);
|
|
427
680
|
const isActive = idx === activeIndex;
|
|
681
|
+
const canRestart = isRestartEnabled();
|
|
428
682
|
|
|
429
683
|
return (
|
|
430
|
-
<
|
|
684
|
+
<TaskCard
|
|
431
685
|
key={item.id ?? idx}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
686
|
+
idx={idx}
|
|
687
|
+
nodeRef={nodeRefs.current[idx]}
|
|
688
|
+
status={status}
|
|
689
|
+
isActive={isActive}
|
|
690
|
+
canRestart={canRestart}
|
|
691
|
+
isSubmitting={isSubmitting}
|
|
692
|
+
getRestartDisabledReason={getRestartDisabledReason}
|
|
436
693
|
onClick={() => {
|
|
437
694
|
setOpenIdx(idx);
|
|
438
|
-
setSelectedFile(null);
|
|
439
695
|
}}
|
|
440
696
|
onKeyDown={(e) => {
|
|
441
697
|
if (e.key === "Enter" || e.key === " ") {
|
|
442
698
|
e.preventDefault();
|
|
443
699
|
setOpenIdx(idx);
|
|
444
|
-
setSelectedFile(null);
|
|
445
700
|
}
|
|
446
701
|
}}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
data-role="card-header"
|
|
451
|
-
className={`rounded-t-lg px-4 py-2 border-b flex items-center justify-between gap-3 ${getHeaderClasses(status)}`}
|
|
452
|
-
>
|
|
453
|
-
<div className="font-medium truncate">
|
|
454
|
-
{formatStepName(item, idx)}
|
|
455
|
-
</div>
|
|
456
|
-
<div className="flex items-center gap-2">
|
|
457
|
-
{status === "running" ? (
|
|
458
|
-
<>
|
|
459
|
-
<div className="relative h-4 w-4" aria-label="Running">
|
|
460
|
-
<span className="sr-only">Running</span>
|
|
461
|
-
<span className="absolute inset-0 rounded-full border-2 border-amber-200" />
|
|
462
|
-
<span className="absolute inset-0 rounded-full border-2 border-transparent border-t-amber-600 animate-spin" />
|
|
463
|
-
</div>
|
|
464
|
-
{item.stage && (
|
|
465
|
-
<span
|
|
466
|
-
className="text-[11px] font-medium opacity-80 truncate"
|
|
467
|
-
title={item.stage}
|
|
468
|
-
>
|
|
469
|
-
{formatStageLabel(item.stage)}
|
|
470
|
-
</span>
|
|
471
|
-
)}
|
|
472
|
-
</>
|
|
473
|
-
) : (
|
|
474
|
-
<span className="text-[11px] uppercase tracking-wide opacity-80">
|
|
475
|
-
{status}
|
|
476
|
-
{status === "failed" && item.stage && (
|
|
477
|
-
<span
|
|
478
|
-
className="text-[11px] font-medium opacity-80 truncate ml-2"
|
|
479
|
-
title={item.stage}
|
|
480
|
-
>
|
|
481
|
-
({formatStageLabel(item.stage)})
|
|
482
|
-
</span>
|
|
483
|
-
)}
|
|
484
|
-
</span>
|
|
485
|
-
)}
|
|
486
|
-
</div>
|
|
487
|
-
</div>
|
|
488
|
-
<div className="p-4">
|
|
489
|
-
{item.subtitle && (
|
|
490
|
-
<div className="text-sm text-gray-600">{item.subtitle}</div>
|
|
491
|
-
)}
|
|
492
|
-
{item.body && (
|
|
493
|
-
<div className="mt-2 text-sm text-gray-700">{item.body}</div>
|
|
494
|
-
)}
|
|
495
|
-
</div>
|
|
496
|
-
</div>
|
|
702
|
+
handleRestartClick={handleRestartClick}
|
|
703
|
+
item={item}
|
|
704
|
+
/>
|
|
497
705
|
);
|
|
498
706
|
})}
|
|
499
707
|
</div>
|
|
500
708
|
|
|
501
|
-
{/*
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
setSelectedFile(null);
|
|
527
|
-
}}
|
|
528
|
-
className="rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 px-3 py-1.5 text-base"
|
|
529
|
-
>
|
|
530
|
-
×
|
|
531
|
-
</button>
|
|
532
|
-
</div>
|
|
533
|
-
<div className="p-6 space-y-8 overflow-y-auto h-full">
|
|
534
|
-
{/* Error Callout - shown when task has error status and body */}
|
|
535
|
-
{items[openIdx]?.status === "failed" && items[openIdx]?.body && (
|
|
536
|
-
<section aria-label="Error">
|
|
537
|
-
<Callout.Root role="alert" aria-live="assertive">
|
|
538
|
-
<Callout.Text className="whitespace-pre-wrap break-words">
|
|
539
|
-
{items[openIdx].body}
|
|
540
|
-
</Callout.Text>
|
|
541
|
-
</Callout.Root>
|
|
542
|
-
</section>
|
|
543
|
-
)}
|
|
544
|
-
|
|
545
|
-
{/* File Display Area with Type Tabs */}
|
|
546
|
-
<section className="mt-6">
|
|
547
|
-
<div className="flex items-center justify-between mb-4">
|
|
548
|
-
<h3 className="text-base font-semibold text-gray-900">
|
|
549
|
-
Files
|
|
550
|
-
</h3>
|
|
551
|
-
<div className="flex items-center space-x-2">
|
|
552
|
-
<div className="flex rounded-lg border border-gray-200 bg-gray-50 p-1">
|
|
553
|
-
<button
|
|
554
|
-
onClick={() => setFilePaneType("artifacts")}
|
|
555
|
-
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
556
|
-
filePaneType === "artifacts"
|
|
557
|
-
? "bg-white text-gray-900 shadow-sm"
|
|
558
|
-
: "text-gray-600 hover:text-gray-900"
|
|
559
|
-
}`}
|
|
560
|
-
>
|
|
561
|
-
Artifacts
|
|
562
|
-
</button>
|
|
563
|
-
<button
|
|
564
|
-
onClick={() => setFilePaneType("logs")}
|
|
565
|
-
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
566
|
-
filePaneType === "logs"
|
|
567
|
-
? "bg-white text-gray-900 shadow-sm"
|
|
568
|
-
: "text-gray-600 hover:text-gray-900"
|
|
569
|
-
}`}
|
|
570
|
-
>
|
|
571
|
-
Logs
|
|
572
|
-
</button>
|
|
573
|
-
<button
|
|
574
|
-
onClick={() => setFilePaneType("tmp")}
|
|
575
|
-
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
576
|
-
filePaneType === "tmp"
|
|
577
|
-
? "bg-white text-gray-900 shadow-sm"
|
|
578
|
-
: "text-gray-600 hover:text-gray-900"
|
|
579
|
-
}`}
|
|
580
|
-
>
|
|
581
|
-
Temp
|
|
582
|
-
</button>
|
|
583
|
-
</div>
|
|
584
|
-
</div>
|
|
585
|
-
</div>
|
|
586
|
-
</section>
|
|
587
|
-
|
|
588
|
-
{/* File List */}
|
|
589
|
-
<div className="space-y-2">
|
|
590
|
-
<div className="text-sm text-gray-600">
|
|
591
|
-
{filePaneType.charAt(0).toUpperCase() + filePaneType.slice(1)}{" "}
|
|
592
|
-
files for {items[openIdx]?.id || `Task ${openIdx + 1}`}
|
|
593
|
-
</div>
|
|
594
|
-
<div className="space-y-1">
|
|
595
|
-
{(() => {
|
|
596
|
-
const filesForStep = filesByTypeForItem(items[openIdx]);
|
|
597
|
-
const filesForTab = filesForStep[filePaneType] ?? [];
|
|
598
|
-
|
|
599
|
-
if (filesForTab.length === 0) {
|
|
600
|
-
return (
|
|
601
|
-
<div className="text-sm text-gray-500 italic py-4 text-center">
|
|
602
|
-
No {filePaneType} files available for this task
|
|
603
|
-
</div>
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return filesForTab.map((name) => {
|
|
608
|
-
return (
|
|
609
|
-
<div
|
|
610
|
-
key={`${filePaneType}-${name}`}
|
|
611
|
-
className="flex items-center justify-between p-2 rounded border border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer transition-colors"
|
|
612
|
-
onClick={() => {
|
|
613
|
-
setFilePaneFilename(name);
|
|
614
|
-
setFilePaneOpen(true);
|
|
615
|
-
}}
|
|
616
|
-
>
|
|
617
|
-
<div className="flex items-center space-x-2">
|
|
618
|
-
<span className="text-sm text-gray-700">
|
|
619
|
-
{name}
|
|
620
|
-
</span>
|
|
621
|
-
</div>
|
|
622
|
-
</div>
|
|
623
|
-
);
|
|
624
|
-
});
|
|
625
|
-
})()}
|
|
626
|
-
</div>
|
|
627
|
-
</div>
|
|
628
|
-
|
|
629
|
-
{/* TaskFilePane Modal */}
|
|
630
|
-
<TaskFilePane
|
|
631
|
-
isOpen={filePaneOpen}
|
|
632
|
-
jobId={jobId}
|
|
633
|
-
taskId={items[openIdx]?.id || `task-${openIdx}`}
|
|
634
|
-
type={filePaneType}
|
|
635
|
-
filename={filePaneFilename}
|
|
636
|
-
onClose={() => {
|
|
637
|
-
setFilePaneOpen(false);
|
|
638
|
-
setFilePaneFilename(null);
|
|
639
|
-
}}
|
|
640
|
-
/>
|
|
641
|
-
</div>
|
|
642
|
-
</>
|
|
643
|
-
)}
|
|
644
|
-
</aside>
|
|
709
|
+
{/* TaskDetailSidebar */}
|
|
710
|
+
{openIdx !== -1 && (
|
|
711
|
+
<TaskDetailSidebar
|
|
712
|
+
open={openIdx !== -1}
|
|
713
|
+
title={formatStepName(items[openIdx], openIdx)}
|
|
714
|
+
status={getStatus(openIdx)}
|
|
715
|
+
jobId={jobId}
|
|
716
|
+
taskId={items[openIdx]?.id || `task-${openIdx}`}
|
|
717
|
+
taskBody={items[openIdx]?.body || null}
|
|
718
|
+
filesByTypeForItem={filesByTypeForItem}
|
|
719
|
+
task={items[openIdx]}
|
|
720
|
+
taskIndex={openIdx}
|
|
721
|
+
onClose={() => setOpenIdx(-1)}
|
|
722
|
+
/>
|
|
723
|
+
)}
|
|
724
|
+
|
|
725
|
+
{/* Restart Job Modal */}
|
|
726
|
+
<RestartJobModal
|
|
727
|
+
open={restartModalOpen}
|
|
728
|
+
onClose={handleRestartCancel}
|
|
729
|
+
onConfirm={handleRestartConfirm}
|
|
730
|
+
jobId={jobId}
|
|
731
|
+
taskId={restartTaskId}
|
|
732
|
+
isSubmitting={isSubmitting}
|
|
733
|
+
/>
|
|
645
734
|
</div>
|
|
646
735
|
);
|
|
647
736
|
}
|