@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.4.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 +415 -24
- package/package.json +46 -8
- package/src/api/files.js +48 -0
- package/src/api/index.js +149 -53
- package/src/api/validators/seed.js +141 -0
- package/src/cli/index.js +444 -29
- package/src/cli/run-orchestrator.js +39 -0
- package/src/cli/update-pipeline-json.js +47 -0
- package/src/components/DAGGrid.jsx +649 -0
- package/src/components/JobCard.jsx +96 -0
- package/src/components/JobDetail.jsx +159 -0
- package/src/components/JobTable.jsx +202 -0
- package/src/components/Layout.jsx +134 -0
- package/src/components/TaskFilePane.jsx +570 -0
- package/src/components/UploadSeed.jsx +239 -0
- package/src/components/ui/badge.jsx +20 -0
- package/src/components/ui/button.jsx +43 -0
- package/src/components/ui/card.jsx +20 -0
- package/src/components/ui/focus-styles.css +60 -0
- package/src/components/ui/progress.jsx +26 -0
- package/src/components/ui/select.jsx +27 -0
- package/src/components/ui/separator.jsx +6 -0
- package/src/config/paths.js +99 -0
- package/src/core/config.js +270 -9
- package/src/core/file-io.js +202 -0
- package/src/core/module-loader.js +157 -0
- package/src/core/orchestrator.js +275 -294
- package/src/core/pipeline-runner.js +95 -41
- package/src/core/progress.js +66 -0
- package/src/core/status-writer.js +331 -0
- package/src/core/task-runner.js +719 -73
- package/src/core/validation.js +120 -1
- package/src/lib/utils.js +6 -0
- package/src/llm/README.md +139 -30
- package/src/llm/index.js +222 -72
- package/src/pages/PipelineDetail.jsx +111 -0
- package/src/pages/PromptPipelineDashboard.jsx +223 -0
- package/src/providers/deepseek.js +3 -15
- package/src/ui/client/adapters/job-adapter.js +258 -0
- package/src/ui/client/bootstrap.js +120 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
- package/src/ui/client/hooks/useJobList.js +50 -0
- package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
- package/src/ui/client/hooks/useTicker.js +26 -0
- package/src/ui/client/index.css +31 -0
- package/src/ui/client/index.html +18 -0
- package/src/ui/client/main.jsx +38 -0
- package/src/ui/config-bridge.browser.js +149 -0
- package/src/ui/config-bridge.js +149 -0
- package/src/ui/config-bridge.node.js +310 -0
- package/src/ui/dist/assets/index-CxcrauYR.js +22702 -0
- package/src/ui/dist/assets/style-D6K_oQ12.css +62 -0
- package/src/ui/dist/index.html +19 -0
- package/src/ui/endpoints/job-endpoints.js +300 -0
- package/src/ui/file-reader.js +216 -0
- package/src/ui/job-change-detector.js +83 -0
- package/src/ui/job-index.js +231 -0
- package/src/ui/job-reader.js +274 -0
- package/src/ui/job-scanner.js +188 -0
- package/src/ui/public/app.js +3 -1
- package/src/ui/server.js +1636 -59
- package/src/ui/sse-enhancer.js +149 -0
- package/src/ui/sse.js +204 -0
- package/src/ui/state-snapshot.js +252 -0
- package/src/ui/transformers/list-transformer.js +347 -0
- package/src/ui/transformers/status-transformer.js +307 -0
- package/src/ui/watcher.js +61 -7
- package/src/utils/dag.js +101 -0
- package/src/utils/duration.js +126 -0
- package/src/utils/id-generator.js +30 -0
- package/src/utils/jobs.js +7 -0
- package/src/utils/pipelines.js +44 -0
- package/src/utils/task-files.js +271 -0
- package/src/utils/ui.jsx +76 -0
- package/src/ui/public/index.html +0 -53
- package/src/ui/public/style.css +0 -341
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useLayoutEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
createRef,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { Callout } from "@radix-ui/themes";
|
|
9
|
+
import { TaskFilePane } from "./TaskFilePane.jsx";
|
|
10
|
+
import { createEmptyTaskFiles } from "../utils/task-files.js";
|
|
11
|
+
|
|
12
|
+
// Helpers: capitalize fallback step ids (upperFirst only; do not alter provided titles)
|
|
13
|
+
function upperFirst(s) {
|
|
14
|
+
return typeof s === "string" && s.length > 0
|
|
15
|
+
? s.charAt(0).toUpperCase() + s.slice(1)
|
|
16
|
+
: s;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Format stage token into human-readable label
|
|
20
|
+
function formatStageLabel(s) {
|
|
21
|
+
if (typeof s !== "string" || s.length === 0) return s;
|
|
22
|
+
|
|
23
|
+
// Replace underscores and hyphens with spaces first
|
|
24
|
+
let processed = s.replace(/[_-]/g, " ");
|
|
25
|
+
|
|
26
|
+
// Add space before capital letters that follow lowercase letters
|
|
27
|
+
processed = processed.replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
28
|
+
|
|
29
|
+
// Split into words and clean up
|
|
30
|
+
const words = processed.trim().split(/\s+/).filter(Boolean);
|
|
31
|
+
|
|
32
|
+
if (words.length === 0) return s;
|
|
33
|
+
|
|
34
|
+
// Handle consecutive capitals by treating them as a single word
|
|
35
|
+
const normalizedWords = words.map((word) => {
|
|
36
|
+
// If word is all caps or mostly caps, treat it as an acronym
|
|
37
|
+
if (word.length > 1 && word === word.toUpperCase()) {
|
|
38
|
+
return word;
|
|
39
|
+
}
|
|
40
|
+
return word;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Lower-case all words except first (which gets upperFirst)
|
|
44
|
+
const [first, ...rest] = normalizedWords;
|
|
45
|
+
return (
|
|
46
|
+
upperFirst(first.toLowerCase()) +
|
|
47
|
+
" " +
|
|
48
|
+
rest.map((w) => w.toLowerCase()).join(" ")
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatStepName(item, idx) {
|
|
53
|
+
const raw = item.title ?? item.id ?? `Step ${idx + 1}`;
|
|
54
|
+
// If item has a title, assume it’s curated and leave unchanged; otherwise capitalize fallback
|
|
55
|
+
return upperFirst(item.title ? item.title : raw);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* DAGGrid component for visualizing pipeline tasks with connectors and slide-over details
|
|
60
|
+
* @param {Object} props
|
|
61
|
+
* @param {Array} props.items - Array of DAG items with id, status, and optional title/subtitle
|
|
62
|
+
* @param {number} props.cols - Number of columns for grid layout (default: 3)
|
|
63
|
+
* @param {string} props.cardClass - Additional CSS classes for cards
|
|
64
|
+
* @param {number} props.activeIndex - Index of the active item
|
|
65
|
+
* @param {string} props.jobId - Job ID for file operations
|
|
66
|
+
* @param {Function} props.filesByTypeForItem - Selector returning { artifacts, logs, tmp }
|
|
67
|
+
*/
|
|
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
|
+
|
|
90
|
+
function DAGGrid({
|
|
91
|
+
items,
|
|
92
|
+
cols = 3,
|
|
93
|
+
cardClass = "",
|
|
94
|
+
activeIndex = 0,
|
|
95
|
+
jobId,
|
|
96
|
+
filesByTypeForItem = () => createEmptyTaskFiles(),
|
|
97
|
+
}) {
|
|
98
|
+
const logger = React.useMemo(() => createDAGGridLogger(jobId), [jobId]);
|
|
99
|
+
|
|
100
|
+
const overlayRef = useRef(null);
|
|
101
|
+
const gridRef = useRef(null);
|
|
102
|
+
const nodeRefs = useRef([]);
|
|
103
|
+
const [lines, setLines] = useState([]);
|
|
104
|
+
const [effectiveCols, setEffectiveCols] = useState(cols);
|
|
105
|
+
const [openIdx, setOpenIdx] = useState(null);
|
|
106
|
+
const [selectedFile, setSelectedFile] = useState(null);
|
|
107
|
+
const [filePaneOpen, setFilePaneOpen] = useState(false);
|
|
108
|
+
const [filePaneType, setFilePaneType] = useState("artifacts");
|
|
109
|
+
const [filePaneFilename, setFilePaneFilename] = useState(null);
|
|
110
|
+
|
|
111
|
+
// Log component props and state changes
|
|
112
|
+
React.useEffect(() => {
|
|
113
|
+
logger.group("Component Render");
|
|
114
|
+
logger.log("Props received:", {
|
|
115
|
+
itemCount: items?.length,
|
|
116
|
+
cols,
|
|
117
|
+
activeIndex,
|
|
118
|
+
jobId,
|
|
119
|
+
});
|
|
120
|
+
logger.log("Items data:", items);
|
|
121
|
+
logger.groupEnd();
|
|
122
|
+
}, [items, cols, activeIndex, jobId, logger]);
|
|
123
|
+
|
|
124
|
+
// Create refs for each node
|
|
125
|
+
nodeRefs.current = useMemo(
|
|
126
|
+
() => items.map((_, i) => nodeRefs.current[i] ?? createRef()),
|
|
127
|
+
[items.length]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Responsive: force single-column on narrow screens
|
|
131
|
+
useLayoutEffect(() => {
|
|
132
|
+
// Skip in test environment
|
|
133
|
+
if (process.env.NODE_ENV === "test") {
|
|
134
|
+
setEffectiveCols(cols);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const mq = window.matchMedia("(min-width: 1024px)");
|
|
139
|
+
const apply = () => setEffectiveCols(mq.matches ? cols : 1);
|
|
140
|
+
apply();
|
|
141
|
+
|
|
142
|
+
const handleChange = () => apply();
|
|
143
|
+
mq.addEventListener
|
|
144
|
+
? mq.addEventListener("change", handleChange)
|
|
145
|
+
: mq.addListener(handleChange);
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
mq.removeEventListener
|
|
149
|
+
? mq.removeEventListener("change", handleChange)
|
|
150
|
+
: mq.removeListener(handleChange);
|
|
151
|
+
};
|
|
152
|
+
}, [cols]);
|
|
153
|
+
|
|
154
|
+
// Calculate visual order for snake-like layout
|
|
155
|
+
const visualOrder = useMemo(() => {
|
|
156
|
+
if (effectiveCols === 1) {
|
|
157
|
+
return Array.from({ length: items.length }, (_, i) => i);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const order = [];
|
|
161
|
+
const rows = Math.ceil(items.length / effectiveCols);
|
|
162
|
+
|
|
163
|
+
for (let r = 0; r < rows; r++) {
|
|
164
|
+
const start = r * effectiveCols;
|
|
165
|
+
const end = Math.min(start + effectiveCols, items.length);
|
|
166
|
+
const slice = Array.from({ length: end - start }, (_, k) => start + k);
|
|
167
|
+
const rowLen = slice.length;
|
|
168
|
+
const pad = Math.max(0, effectiveCols - rowLen);
|
|
169
|
+
|
|
170
|
+
if (r % 2 === 1) {
|
|
171
|
+
// Reverse order for odd rows (snake pattern)
|
|
172
|
+
const reversed = slice.reverse();
|
|
173
|
+
order.push(...Array(pad).fill(-1), ...reversed);
|
|
174
|
+
} else {
|
|
175
|
+
order.push(...slice);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return order;
|
|
180
|
+
}, [items.length, effectiveCols]);
|
|
181
|
+
|
|
182
|
+
// Calculate connector lines between cards
|
|
183
|
+
useLayoutEffect(() => {
|
|
184
|
+
// Skip entirely in test environment to prevent hanging
|
|
185
|
+
if (process.env.NODE_ENV === "test") {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Skip if no window or no items
|
|
190
|
+
if (
|
|
191
|
+
typeof window === "undefined" ||
|
|
192
|
+
!overlayRef.current ||
|
|
193
|
+
items.length === 0
|
|
194
|
+
) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let isComputing = false;
|
|
199
|
+
const compute = () => {
|
|
200
|
+
if (isComputing) return; // Prevent infinite loops
|
|
201
|
+
isComputing = true;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
if (!overlayRef.current) return;
|
|
205
|
+
|
|
206
|
+
const overlayBox = overlayRef.current.getBoundingClientRect();
|
|
207
|
+
const boxes = nodeRefs.current.map((r) => {
|
|
208
|
+
const el = r.current;
|
|
209
|
+
if (!el) return null;
|
|
210
|
+
|
|
211
|
+
const b = el.getBoundingClientRect();
|
|
212
|
+
const headerEl = el.querySelector('[data-role="card-header"]');
|
|
213
|
+
const hr = headerEl ? headerEl.getBoundingClientRect() : null;
|
|
214
|
+
const headerMidY = hr
|
|
215
|
+
? hr.top - overlayBox.top + hr.height / 2
|
|
216
|
+
: b.top - overlayBox.top + Math.min(24, b.height / 6);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
left: b.left - overlayBox.left,
|
|
220
|
+
top: b.top - overlayBox.top,
|
|
221
|
+
width: b.width,
|
|
222
|
+
height: b.height,
|
|
223
|
+
right: b.right - overlayBox.left,
|
|
224
|
+
bottom: b.bottom - overlayBox.top,
|
|
225
|
+
cx: b.left - overlayBox.left + b.width / 2,
|
|
226
|
+
cy: b.top - overlayBox.top + b.height / 2,
|
|
227
|
+
headerMidY,
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const newLines = [];
|
|
232
|
+
for (let i = 0; i < items.length - 1; i++) {
|
|
233
|
+
const a = boxes[i];
|
|
234
|
+
const b = boxes[i + 1];
|
|
235
|
+
if (!a || !b) continue;
|
|
236
|
+
|
|
237
|
+
const rowA = Math.floor(i / effectiveCols);
|
|
238
|
+
const rowB = Math.floor((i + 1) / effectiveCols);
|
|
239
|
+
const sameRow = rowA === rowB;
|
|
240
|
+
|
|
241
|
+
if (sameRow) {
|
|
242
|
+
// Horizontal connection
|
|
243
|
+
const leftToRight = rowA % 2 === 0;
|
|
244
|
+
if (leftToRight) {
|
|
245
|
+
const start = { x: a.right, y: a.headerMidY };
|
|
246
|
+
const end = { x: b.left, y: b.headerMidY };
|
|
247
|
+
const midX = (start.x + end.x) / 2;
|
|
248
|
+
newLines.push({
|
|
249
|
+
d: `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`,
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
const start = { x: a.left, y: a.headerMidY };
|
|
253
|
+
const end = { x: b.right, y: b.headerMidY };
|
|
254
|
+
const midX = (start.x + end.x) / 2;
|
|
255
|
+
newLines.push({
|
|
256
|
+
d: `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
// Vertical connection
|
|
261
|
+
const start = { x: a.cx, y: a.bottom };
|
|
262
|
+
const end = { x: b.cx, y: b.top };
|
|
263
|
+
const midY = (start.y + end.y) / 2;
|
|
264
|
+
newLines.push({
|
|
265
|
+
d: `M ${start.x} ${start.y} L ${start.x} ${midY} L ${end.x} ${midY} L ${end.x} ${end.y}`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setLines(newLines);
|
|
271
|
+
} finally {
|
|
272
|
+
isComputing = false;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Initial compute
|
|
277
|
+
compute();
|
|
278
|
+
|
|
279
|
+
// Set up observers only if ResizeObserver is available and not in test
|
|
280
|
+
let ro = null;
|
|
281
|
+
if (
|
|
282
|
+
typeof ResizeObserver !== "undefined" &&
|
|
283
|
+
process.env.NODE_ENV !== "test"
|
|
284
|
+
) {
|
|
285
|
+
ro = new ResizeObserver(compute);
|
|
286
|
+
if (gridRef.current) ro.observe(gridRef.current);
|
|
287
|
+
nodeRefs.current.forEach((r) => r.current && ro.observe(r.current));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const handleResize = () => compute();
|
|
291
|
+
const handleScroll = () => compute();
|
|
292
|
+
|
|
293
|
+
window.addEventListener("resize", handleResize);
|
|
294
|
+
window.addEventListener("scroll", handleScroll, true);
|
|
295
|
+
|
|
296
|
+
return () => {
|
|
297
|
+
if (ro) ro.disconnect();
|
|
298
|
+
window.removeEventListener("resize", handleResize);
|
|
299
|
+
window.removeEventListener("scroll", handleScroll, true);
|
|
300
|
+
};
|
|
301
|
+
}, [items, effectiveCols, visualOrder]);
|
|
302
|
+
|
|
303
|
+
// Get status for a given item index with fallback to activeIndex
|
|
304
|
+
const getStatus = (index) => {
|
|
305
|
+
const item = items[index];
|
|
306
|
+
const s = item?.status;
|
|
307
|
+
if (s === "failed") return "failed";
|
|
308
|
+
if (s === "done") return "done";
|
|
309
|
+
if (s === "running") return "running";
|
|
310
|
+
if (typeof activeIndex === "number") {
|
|
311
|
+
if (index < activeIndex) return "done";
|
|
312
|
+
if (index === activeIndex) return "running";
|
|
313
|
+
return "pending";
|
|
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";
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Handle Escape key to close slide-over
|
|
333
|
+
React.useEffect(() => {
|
|
334
|
+
const handleKeyDown = (e) => {
|
|
335
|
+
if (e.key === "Escape" && openIdx !== null) {
|
|
336
|
+
setOpenIdx(null);
|
|
337
|
+
setSelectedFile(null);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
if (openIdx !== null) {
|
|
342
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
343
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
344
|
+
}
|
|
345
|
+
}, [openIdx]);
|
|
346
|
+
|
|
347
|
+
// Focus management for slide-over
|
|
348
|
+
const closeButtonRef = useRef(null);
|
|
349
|
+
React.useEffect(() => {
|
|
350
|
+
if (openIdx !== null && closeButtonRef.current) {
|
|
351
|
+
closeButtonRef.current.focus();
|
|
352
|
+
}
|
|
353
|
+
}, [openIdx]);
|
|
354
|
+
|
|
355
|
+
React.useEffect(() => {
|
|
356
|
+
setFilePaneFilename(null);
|
|
357
|
+
setFilePaneOpen(false);
|
|
358
|
+
}, [filePaneType]);
|
|
359
|
+
|
|
360
|
+
React.useEffect(() => {
|
|
361
|
+
if (openIdx === null) {
|
|
362
|
+
setFilePaneFilename(null);
|
|
363
|
+
setFilePaneOpen(false);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
setFilePaneType("artifacts");
|
|
367
|
+
setFilePaneFilename(null);
|
|
368
|
+
setFilePaneOpen(false);
|
|
369
|
+
}, [openIdx]);
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<div className="relative w-full" role="list">
|
|
373
|
+
{/* SVG overlay for connector lines */}
|
|
374
|
+
<svg
|
|
375
|
+
ref={overlayRef}
|
|
376
|
+
className="absolute inset-0 w-full h-full pointer-events-none z-10"
|
|
377
|
+
aria-hidden="true"
|
|
378
|
+
>
|
|
379
|
+
<defs>
|
|
380
|
+
<marker
|
|
381
|
+
id="arrow"
|
|
382
|
+
viewBox="0 0 10 10"
|
|
383
|
+
refX="10"
|
|
384
|
+
refY="5"
|
|
385
|
+
markerWidth="8"
|
|
386
|
+
markerHeight="8"
|
|
387
|
+
orient="auto"
|
|
388
|
+
markerUnits="userSpaceOnUse"
|
|
389
|
+
>
|
|
390
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#9ca3af" />
|
|
391
|
+
</marker>
|
|
392
|
+
</defs>
|
|
393
|
+
{lines.map((line, idx) => (
|
|
394
|
+
<g key={idx}>
|
|
395
|
+
<path
|
|
396
|
+
d={line.d}
|
|
397
|
+
fill="none"
|
|
398
|
+
stroke="currentColor"
|
|
399
|
+
strokeWidth="3"
|
|
400
|
+
strokeLinecap="round"
|
|
401
|
+
className="text-gray-300"
|
|
402
|
+
strokeLinejoin="round"
|
|
403
|
+
markerEnd="url(#arrow)"
|
|
404
|
+
/>
|
|
405
|
+
</g>
|
|
406
|
+
))}
|
|
407
|
+
</svg>
|
|
408
|
+
|
|
409
|
+
{/* Grid of task cards */}
|
|
410
|
+
<div
|
|
411
|
+
ref={gridRef}
|
|
412
|
+
className="grid grid-cols-1 lg:grid-cols-3 gap-16 relative z-0"
|
|
413
|
+
>
|
|
414
|
+
{visualOrder.map((idx, mapIndex) => {
|
|
415
|
+
if (idx === -1) {
|
|
416
|
+
return (
|
|
417
|
+
<div
|
|
418
|
+
key={`ghost-${mapIndex}`}
|
|
419
|
+
className="invisible"
|
|
420
|
+
aria-hidden="true"
|
|
421
|
+
/>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const item = items[idx];
|
|
426
|
+
const status = getStatus(idx);
|
|
427
|
+
const isActive = idx === activeIndex;
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<div
|
|
431
|
+
key={item.id ?? idx}
|
|
432
|
+
ref={nodeRefs.current[idx]}
|
|
433
|
+
role="listitem"
|
|
434
|
+
aria-current={isActive ? "step" : undefined}
|
|
435
|
+
tabIndex={0}
|
|
436
|
+
onClick={() => {
|
|
437
|
+
setOpenIdx(idx);
|
|
438
|
+
setSelectedFile(null);
|
|
439
|
+
}}
|
|
440
|
+
onKeyDown={(e) => {
|
|
441
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
442
|
+
e.preventDefault();
|
|
443
|
+
setOpenIdx(idx);
|
|
444
|
+
setSelectedFile(null);
|
|
445
|
+
}
|
|
446
|
+
}}
|
|
447
|
+
className={`cursor-pointer rounded-lg border border-gray-400 bg-white overflow-hidden flex flex-col transition outline outline-2 outline-transparent hover:outline-gray-400/70 focus-visible:outline-blue-500/60 ${cardClass}`}
|
|
448
|
+
>
|
|
449
|
+
<div
|
|
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>
|
|
497
|
+
);
|
|
498
|
+
})}
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
{/* Slide-over panel for task details */}
|
|
502
|
+
<aside
|
|
503
|
+
role="dialog"
|
|
504
|
+
aria-modal="true"
|
|
505
|
+
aria-labelledby={`slide-over-title-${openIdx}`}
|
|
506
|
+
aria-hidden={openIdx === null}
|
|
507
|
+
className={`fixed inset-y-0 right-0 z-[2000] w-full max-w-4xl bg-white border-l border-gray-200 transform transition-transform duration-300 ease-out ${openIdx !== null ? "translate-x-0" : "translate-x-full"}`}
|
|
508
|
+
>
|
|
509
|
+
{openIdx !== null && (
|
|
510
|
+
<>
|
|
511
|
+
<div
|
|
512
|
+
className={`px-6 py-4 border-b flex items-center justify-between ${getHeaderClasses(getStatus(openIdx))}`}
|
|
513
|
+
>
|
|
514
|
+
<div
|
|
515
|
+
id={`slide-over-title-${openIdx}`}
|
|
516
|
+
className="text-lg font-semibold truncate"
|
|
517
|
+
>
|
|
518
|
+
{formatStepName(items[openIdx], openIdx)}
|
|
519
|
+
</div>
|
|
520
|
+
<button
|
|
521
|
+
ref={closeButtonRef}
|
|
522
|
+
type="button"
|
|
523
|
+
aria-label="Close details"
|
|
524
|
+
onClick={() => {
|
|
525
|
+
setOpenIdx(null);
|
|
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>
|
|
645
|
+
</div>
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export default DAGGrid;
|