flowyml 1.4.0__py3-none-any.whl → 1.6.0__py3-none-any.whl
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.
- flowyml/__init__.py +2 -1
- flowyml/assets/featureset.py +30 -5
- flowyml/assets/metrics.py +47 -4
- flowyml/cli/main.py +21 -0
- flowyml/cli/models.py +444 -0
- flowyml/cli/rich_utils.py +95 -0
- flowyml/core/checkpoint.py +6 -1
- flowyml/core/conditional.py +104 -0
- flowyml/core/display.py +525 -0
- flowyml/core/execution_status.py +1 -0
- flowyml/core/executor.py +201 -8
- flowyml/core/orchestrator.py +500 -7
- flowyml/core/pipeline.py +301 -11
- flowyml/core/project.py +4 -1
- flowyml/core/scheduler.py +225 -81
- flowyml/core/versioning.py +13 -4
- flowyml/registry/model_registry.py +1 -1
- flowyml/storage/sql.py +53 -13
- flowyml/ui/backend/main.py +2 -0
- flowyml/ui/backend/routers/assets.py +36 -0
- flowyml/ui/backend/routers/execution.py +2 -2
- flowyml/ui/backend/routers/runs.py +211 -0
- flowyml/ui/backend/routers/stats.py +2 -2
- flowyml/ui/backend/routers/websocket.py +121 -0
- flowyml/ui/frontend/dist/assets/index-By4trVyv.css +1 -0
- flowyml/ui/frontend/dist/assets/index-CX5RV2C9.js +630 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/package-lock.json +289 -0
- flowyml/ui/frontend/package.json +1 -0
- flowyml/ui/frontend/src/app/compare/page.jsx +213 -0
- flowyml/ui/frontend/src/app/experiments/compare/page.jsx +289 -0
- flowyml/ui/frontend/src/app/experiments/page.jsx +61 -1
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +418 -203
- flowyml/ui/frontend/src/app/runs/page.jsx +64 -3
- flowyml/ui/frontend/src/app/settings/page.jsx +1 -1
- flowyml/ui/frontend/src/app/tokens/page.jsx +8 -6
- flowyml/ui/frontend/src/components/ArtifactViewer.jsx +159 -0
- flowyml/ui/frontend/src/components/NavigationTree.jsx +26 -9
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +69 -28
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +42 -14
- flowyml/ui/frontend/src/router/index.jsx +4 -0
- flowyml/ui/server_manager.py +181 -0
- flowyml/ui/utils.py +63 -1
- flowyml/utils/config.py +7 -0
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/METADATA +5 -3
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/RECORD +49 -41
- flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +0 -1
- flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +0 -592
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/WHEEL +0 -0
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/entry_points.txt +0 -0
- {flowyml-1.4.0.dist-info → flowyml-1.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -8,7 +8,9 @@ import { Badge } from '../../../components/ui/Badge';
|
|
|
8
8
|
import { Button } from '../../../components/ui/Button';
|
|
9
9
|
import { format } from 'date-fns';
|
|
10
10
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
11
|
+
import { ArtifactViewer } from '../../../components/ArtifactViewer';
|
|
11
12
|
import { PipelineGraph } from '../../../components/PipelineGraph';
|
|
13
|
+
import { ProjectSelector } from '../../../components/ProjectSelector';
|
|
12
14
|
import { CodeSnippet } from '../../../components/ui/CodeSnippet';
|
|
13
15
|
|
|
14
16
|
export function RunDetails() {
|
|
@@ -22,6 +24,41 @@ export function RunDetails() {
|
|
|
22
24
|
const [selectedArtifact, setSelectedArtifact] = useState(null);
|
|
23
25
|
const [cloudStatus, setCloudStatus] = useState(null);
|
|
24
26
|
const [isPolling, setIsPolling] = useState(false);
|
|
27
|
+
const [stopping, setStopping] = useState(false);
|
|
28
|
+
const [stepLogs, setStepLogs] = useState({});
|
|
29
|
+
|
|
30
|
+
const handleStopRun = async () => {
|
|
31
|
+
if (!confirm('Are you sure you want to stop this run?')) return;
|
|
32
|
+
|
|
33
|
+
setStopping(true);
|
|
34
|
+
try {
|
|
35
|
+
await fetchApi(`/api/runs/${runId}/stop`, {
|
|
36
|
+
method: 'POST'
|
|
37
|
+
});
|
|
38
|
+
// Refresh run data
|
|
39
|
+
const res = await fetchApi(`/api/runs/${runId}`);
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
setRun(data);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('Failed to stop run:', error);
|
|
44
|
+
alert('Failed to stop run');
|
|
45
|
+
} finally {
|
|
46
|
+
setStopping(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleProjectUpdate = async (newProject) => {
|
|
51
|
+
try {
|
|
52
|
+
await fetchApi(`/api/runs/${runId}/project`, {
|
|
53
|
+
method: 'PUT',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ project_name: newProject })
|
|
56
|
+
});
|
|
57
|
+
setRun(prev => ({ ...prev, project: newProject }));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Failed to update project:', error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
25
62
|
|
|
26
63
|
// Fetch cloud status for remote runs
|
|
27
64
|
useEffect(() => {
|
|
@@ -118,22 +155,22 @@ export function RunDetails() {
|
|
|
118
155
|
return (
|
|
119
156
|
<div className="space-y-6">
|
|
120
157
|
{/* Header */}
|
|
121
|
-
<div className="flex items-center justify-between bg-white p-6 rounded-xl border border-slate-100 shadow-sm">
|
|
158
|
+
<div className="flex items-center justify-between bg-white dark:bg-slate-800 p-6 rounded-xl border border-slate-100 dark:border-slate-700 shadow-sm">
|
|
122
159
|
<div>
|
|
123
160
|
<div className="flex items-center gap-2 mb-2">
|
|
124
161
|
<Link to="/runs" className="text-sm text-slate-500 hover:text-slate-700 transition-colors">Runs</Link>
|
|
125
162
|
<ChevronRight size={14} className="text-slate-300" />
|
|
126
|
-
<span className="text-sm text-slate-900 font-medium">{run.run_id}</span>
|
|
163
|
+
<span className="text-sm text-slate-900 dark:text-white font-medium">{run.run_id}</span>
|
|
127
164
|
</div>
|
|
128
|
-
<h2 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
|
165
|
+
<h2 className="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
|
129
166
|
<div className={`w-3 h-3 rounded-full ${run.status === 'completed' ? 'bg-emerald-500' : run.status === 'failed' ? 'bg-rose-500' : 'bg-amber-500'}`} />
|
|
130
167
|
Run: <span className="font-mono text-slate-500">{run.run_id.substring(0, 8)}</span>
|
|
131
168
|
</h2>
|
|
132
|
-
<p className="text-slate-500 mt-1 flex items-center gap-2">
|
|
169
|
+
<p className="text-slate-500 dark:text-slate-400 mt-1 flex items-center gap-2">
|
|
133
170
|
<Layers size={16} />
|
|
134
|
-
Pipeline: <span className="font-medium text-slate-700">{run.pipeline_name}</span>
|
|
171
|
+
Pipeline: <span className="font-medium text-slate-700 dark:text-slate-200">{run.pipeline_name}</span>
|
|
135
172
|
{cloudStatus?.is_remote && (
|
|
136
|
-
<Badge variant="secondary" className="text-xs bg-blue-50 text-blue-600 flex items-center gap-1">
|
|
173
|
+
<Badge variant="secondary" className="text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 flex items-center gap-1">
|
|
137
174
|
<Cloud size={12} />
|
|
138
175
|
{cloudStatus.orchestrator_type}
|
|
139
176
|
</Badge>
|
|
@@ -148,12 +185,23 @@ export function RunDetails() {
|
|
|
148
185
|
</div>
|
|
149
186
|
<div className="flex flex-col items-end gap-2">
|
|
150
187
|
<div className="flex items-center gap-2">
|
|
151
|
-
<
|
|
188
|
+
<ProjectSelector currentProject={run.project} onUpdate={handleProjectUpdate} />
|
|
152
189
|
<Badge variant={statusVariant} className="text-sm px-4 py-1.5 uppercase tracking-wide shadow-sm">
|
|
153
190
|
{cloudStatus?.cloud_status?.status || run.status}
|
|
154
191
|
</Badge>
|
|
155
192
|
</div>
|
|
156
193
|
<span className="text-xs text-slate-400 font-mono">ID: {run.run_id}</span>
|
|
194
|
+
{run.status === 'running' && (
|
|
195
|
+
<Button
|
|
196
|
+
variant="danger"
|
|
197
|
+
size="sm"
|
|
198
|
+
onClick={handleStopRun}
|
|
199
|
+
disabled={stopping}
|
|
200
|
+
className="mt-2"
|
|
201
|
+
>
|
|
202
|
+
{stopping ? 'Stopping...' : 'Stop Run'}
|
|
203
|
+
</Button>
|
|
204
|
+
)}
|
|
157
205
|
</div>
|
|
158
206
|
</div>
|
|
159
207
|
|
|
@@ -183,16 +231,28 @@ export function RunDetails() {
|
|
|
183
231
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
184
232
|
{/* DAG Visualization - 2 columns */}
|
|
185
233
|
<div className="lg:col-span-2">
|
|
186
|
-
<h3 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
|
234
|
+
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
|
|
187
235
|
<Activity className="text-primary-500" /> Pipeline Execution Graph
|
|
188
236
|
</h3>
|
|
189
|
-
<div className="h-[600px]">
|
|
237
|
+
<div className="h-[calc(100vh-240px)] min-h-[600px]">
|
|
190
238
|
{run.dag ? (
|
|
191
239
|
<PipelineGraph
|
|
192
240
|
dag={run.dag}
|
|
193
241
|
steps={run.steps}
|
|
194
242
|
selectedStep={selectedStep}
|
|
195
243
|
onStepSelect={setSelectedStep}
|
|
244
|
+
onArtifactSelect={(name) => {
|
|
245
|
+
// Find the artifact object by name
|
|
246
|
+
// We look in 'artifacts' array which contains all assets for the run
|
|
247
|
+
const found = artifacts.find(a => a.name === name);
|
|
248
|
+
if (found) {
|
|
249
|
+
setSelectedArtifact(found);
|
|
250
|
+
} else {
|
|
251
|
+
// Fallback if not found (e.g. might be an intermediate artifact not persisted)
|
|
252
|
+
console.warn(`Artifact ${name} not found in assets list`);
|
|
253
|
+
// Optionally show a toast or alert
|
|
254
|
+
}
|
|
255
|
+
}}
|
|
196
256
|
/>
|
|
197
257
|
) : (
|
|
198
258
|
<Card className="h-full flex items-center justify-center">
|
|
@@ -204,32 +264,55 @@ export function RunDetails() {
|
|
|
204
264
|
|
|
205
265
|
{/* Step Details Panel - 1 column */}
|
|
206
266
|
<div>
|
|
207
|
-
<h3 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
|
267
|
+
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2">
|
|
208
268
|
<Info className="text-primary-500" /> Step Details
|
|
209
269
|
</h3>
|
|
210
270
|
|
|
211
271
|
{selectedStepData ? (
|
|
212
272
|
<Card className="overflow-hidden">
|
|
213
273
|
{/* Step Header */}
|
|
214
|
-
<div className="pb-4 border-b border-slate-100">
|
|
215
|
-
<h4 className="text-lg font-bold text-slate-900 mb-2">{selectedStep}</h4>
|
|
274
|
+
<div className="pb-4 border-b border-slate-100 dark:border-slate-700">
|
|
275
|
+
<h4 className="text-lg font-bold text-slate-900 dark:text-white mb-2">{selectedStep}</h4>
|
|
216
276
|
<div className="flex items-center gap-2">
|
|
217
277
|
<Badge variant={selectedStepData.success ? 'success' : 'danger'} className="text-xs">
|
|
218
278
|
{selectedStepData.success ? 'Success' : 'Failed'}
|
|
219
279
|
</Badge>
|
|
220
280
|
{selectedStepData.cached && (
|
|
221
|
-
<Badge variant="secondary" className="text-xs bg-blue-50 text-blue-700">
|
|
281
|
+
<Badge variant="secondary" className="text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
|
222
282
|
Cached
|
|
223
283
|
</Badge>
|
|
224
284
|
)}
|
|
225
|
-
<span className="text-xs font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
|
|
285
|
+
<span className="text-xs font-mono text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded">
|
|
226
286
|
{selectedStepData.duration?.toFixed(2)}s
|
|
227
287
|
</span>
|
|
228
288
|
</div>
|
|
229
289
|
</div>
|
|
230
290
|
|
|
291
|
+
{/* Live Heartbeat Indicator (Header) */}
|
|
292
|
+
{selectedStepData.status === 'running' && (
|
|
293
|
+
<div className="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-100 dark:border-blue-800 flex items-center justify-between">
|
|
294
|
+
<div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-300">
|
|
295
|
+
<Activity size={14} className="animate-pulse" />
|
|
296
|
+
<span>Step is running</span>
|
|
297
|
+
</div>
|
|
298
|
+
{selectedStepData.last_heartbeat && (
|
|
299
|
+
<span className="text-xs text-blue-600 dark:text-blue-400 font-mono">
|
|
300
|
+
Last heartbeat: {((Date.now() / 1000) - selectedStepData.last_heartbeat).toFixed(1)}s ago
|
|
301
|
+
</span>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
{selectedStepData.status === 'dead' && (
|
|
306
|
+
<div className="px-4 py-2 bg-rose-50 dark:bg-rose-900/20 border-b border-rose-100 dark:border-rose-800 flex items-center gap-2">
|
|
307
|
+
<AlertCircle size={14} className="text-rose-600 dark:text-rose-400" />
|
|
308
|
+
<span className="text-sm font-medium text-rose-700 dark:text-rose-300">
|
|
309
|
+
Step detected as DEAD (missed heartbeats)
|
|
310
|
+
</span>
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
|
|
231
314
|
{/* Tabs */}
|
|
232
|
-
<div className="flex gap-2 border-b border-slate-100 mt-4">
|
|
315
|
+
<div className="flex gap-2 border-b border-slate-100 dark:border-slate-700 mt-4">
|
|
233
316
|
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
|
|
234
317
|
<Info size={16} /> Overview
|
|
235
318
|
</TabButton>
|
|
@@ -239,12 +322,20 @@ export function RunDetails() {
|
|
|
239
322
|
<TabButton active={activeTab === 'artifacts'} onClick={() => setActiveTab('artifacts')}>
|
|
240
323
|
<Package size={16} /> Artifacts
|
|
241
324
|
</TabButton>
|
|
325
|
+
<TabButton active={activeTab === 'logs'} onClick={() => setActiveTab('logs')} data-tab="logs">
|
|
326
|
+
<Terminal size={16} /> Logs
|
|
327
|
+
</TabButton>
|
|
242
328
|
</div>
|
|
243
329
|
|
|
244
330
|
{/* Tab Content */}
|
|
245
331
|
<div className="mt-4 max-h-[450px] overflow-y-auto">
|
|
246
332
|
{activeTab === 'overview' && (
|
|
247
|
-
<OverviewTab
|
|
333
|
+
<OverviewTab
|
|
334
|
+
stepData={selectedStepData}
|
|
335
|
+
metrics={selectedStepMetrics}
|
|
336
|
+
runId={runId}
|
|
337
|
+
stepName={selectedStep}
|
|
338
|
+
/>
|
|
248
339
|
)}
|
|
249
340
|
{activeTab === 'code' && (
|
|
250
341
|
<CodeTab sourceCode={selectedStepData.source_code} />
|
|
@@ -255,6 +346,13 @@ export function RunDetails() {
|
|
|
255
346
|
onArtifactClick={setSelectedArtifact}
|
|
256
347
|
/>
|
|
257
348
|
)}
|
|
349
|
+
{activeTab === 'logs' && (
|
|
350
|
+
<LogsViewer
|
|
351
|
+
runId={runId}
|
|
352
|
+
stepName={selectedStep}
|
|
353
|
+
isRunning={run.status === 'running'}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
258
356
|
</div>
|
|
259
357
|
</Card>
|
|
260
358
|
) : (
|
|
@@ -288,23 +386,24 @@ function StatsCard({ icon, label, value, color }) {
|
|
|
288
386
|
{icon}
|
|
289
387
|
</div>
|
|
290
388
|
<div>
|
|
291
|
-
<p className="text-sm text-slate-500 font-medium">{label}</p>
|
|
292
|
-
<p className="text-xl font-bold text-slate-900">{value}</p>
|
|
389
|
+
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">{label}</p>
|
|
390
|
+
<p className="text-xl font-bold text-slate-900 dark:text-white">{value}</p>
|
|
293
391
|
</div>
|
|
294
392
|
</div>
|
|
295
393
|
</Card>
|
|
296
394
|
);
|
|
297
395
|
}
|
|
298
396
|
|
|
299
|
-
function TabButton({ active, onClick, children }) {
|
|
397
|
+
function TabButton({ active, onClick, children, ...props }) {
|
|
300
398
|
return (
|
|
301
399
|
<button
|
|
302
400
|
onClick={onClick}
|
|
401
|
+
{...props}
|
|
303
402
|
className={`
|
|
304
|
-
flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors
|
|
403
|
+
flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors border-b-2
|
|
305
404
|
${active
|
|
306
|
-
? 'text-primary-600 border-
|
|
307
|
-
: 'text-slate-500 hover:text-slate-700'
|
|
405
|
+
? 'text-primary-600 dark:text-primary-400 border-primary-600 dark:border-primary-400'
|
|
406
|
+
: 'text-slate-500 dark:text-slate-400 border-transparent hover:text-slate-700 dark:hover:text-slate-200'
|
|
308
407
|
}
|
|
309
408
|
`}
|
|
310
409
|
>
|
|
@@ -313,7 +412,7 @@ function TabButton({ active, onClick, children }) {
|
|
|
313
412
|
);
|
|
314
413
|
}
|
|
315
414
|
|
|
316
|
-
function OverviewTab({ stepData, metrics }) {
|
|
415
|
+
function OverviewTab({ stepData, metrics, runId, stepName }) {
|
|
317
416
|
const formatDuration = (seconds) => {
|
|
318
417
|
if (!seconds) return 'N/A';
|
|
319
418
|
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
|
|
@@ -327,22 +426,27 @@ function OverviewTab({ stepData, metrics }) {
|
|
|
327
426
|
<div className="space-y-6">
|
|
328
427
|
{/* Status & Execution Info */}
|
|
329
428
|
<div className="grid grid-cols-2 gap-4">
|
|
330
|
-
<div className="p-4 bg-gradient-to-br from-slate-50 to-white rounded-xl border border-slate-200">
|
|
429
|
+
<div className="p-4 bg-gradient-to-br from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 rounded-xl border border-slate-200 dark:border-slate-700">
|
|
331
430
|
<div className="flex items-center gap-2 mb-2">
|
|
332
431
|
<Clock size={14} className="text-slate-400" />
|
|
333
|
-
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">Duration</span>
|
|
432
|
+
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">Duration</span>
|
|
334
433
|
</div>
|
|
335
|
-
<p className="text-2xl font-bold text-slate-900">
|
|
434
|
+
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
|
336
435
|
{formatDuration(stepData.duration)}
|
|
337
436
|
</p>
|
|
338
437
|
</div>
|
|
339
|
-
<div className="p-4 bg-gradient-to-br from-slate-50 to-white rounded-xl border border-slate-200">
|
|
438
|
+
<div className="p-4 bg-gradient-to-br from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 rounded-xl border border-slate-200 dark:border-slate-700">
|
|
340
439
|
<div className="flex items-center gap-2 mb-2">
|
|
341
440
|
<Activity size={14} className="text-slate-400" />
|
|
342
|
-
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">Status</span>
|
|
441
|
+
<span className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">Status</span>
|
|
343
442
|
</div>
|
|
344
443
|
<div className="flex items-center gap-2">
|
|
345
|
-
{stepData.
|
|
444
|
+
{stepData.status === 'dead' ? (
|
|
445
|
+
<>
|
|
446
|
+
<AlertCircle size={20} className="text-rose-500" />
|
|
447
|
+
<span className="text-lg font-bold text-rose-700 dark:text-rose-400">Dead</span>
|
|
448
|
+
</>
|
|
449
|
+
) : stepData.success ? (
|
|
346
450
|
<>
|
|
347
451
|
<CheckCircle size={20} className="text-emerald-500" />
|
|
348
452
|
<span className="text-lg font-bold text-emerald-700">Success</span>
|
|
@@ -355,120 +459,174 @@ function OverviewTab({ stepData, metrics }) {
|
|
|
355
459
|
) : (
|
|
356
460
|
<>
|
|
357
461
|
<Clock size={20} className="text-amber-500" />
|
|
358
|
-
<span className="text-lg font-bold text-amber-700">Pending</span>
|
|
462
|
+
<span className="text-lg font-bold text-amber-700 dark:text-amber-500">Pending</span>
|
|
359
463
|
</>
|
|
360
464
|
)}
|
|
361
465
|
</div>
|
|
362
466
|
</div>
|
|
363
467
|
</div>
|
|
364
468
|
|
|
469
|
+
{/* Heartbeat Card */}
|
|
470
|
+
{
|
|
471
|
+
stepData.last_heartbeat && (
|
|
472
|
+
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
|
473
|
+
<h5 className="text-sm font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wide mb-2 flex items-center gap-2">
|
|
474
|
+
<Activity size={16} className="text-blue-500" />
|
|
475
|
+
System Heartbeat
|
|
476
|
+
</h5>
|
|
477
|
+
<div className="flex items-center justify-between">
|
|
478
|
+
<span className="text-sm text-slate-500 dark:text-slate-400">Last received:</span>
|
|
479
|
+
<span className="font-mono font-medium text-slate-900 dark:text-white">
|
|
480
|
+
{new Date(stepData.last_heartbeat * 1000).toLocaleTimeString()}
|
|
481
|
+
<span className="text-xs text-slate-400 ml-2">
|
|
482
|
+
({((Date.now() / 1000) - stepData.last_heartbeat).toFixed(1)}s ago)
|
|
483
|
+
</span>
|
|
484
|
+
</span>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
{/* Logs Preview (All Statuses) */}
|
|
491
|
+
<div className="mt-6">
|
|
492
|
+
<h5 className="text-sm font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
493
|
+
<Terminal size={16} />
|
|
494
|
+
Logs Preview
|
|
495
|
+
</h5>
|
|
496
|
+
<LogsViewer
|
|
497
|
+
runId={runId}
|
|
498
|
+
stepName={stepName}
|
|
499
|
+
isRunning={stepData.status === 'running'}
|
|
500
|
+
maxHeight="max-h-48"
|
|
501
|
+
minimal={true}
|
|
502
|
+
/>
|
|
503
|
+
<div className="mt-2 text-right">
|
|
504
|
+
<button
|
|
505
|
+
onClick={() => document.querySelector('[data-tab="logs"]')?.click()}
|
|
506
|
+
className="text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400 font-medium"
|
|
507
|
+
>
|
|
508
|
+
View Full Logs →
|
|
509
|
+
</button>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
365
513
|
{/* Inputs & Outputs */}
|
|
366
|
-
{
|
|
367
|
-
|
|
368
|
-
<
|
|
369
|
-
<
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
<div className="
|
|
376
|
-
<
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
<
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
)}
|
|
390
|
-
{stepData.outputs?.length > 0 && (
|
|
391
|
-
<div className="p-4 bg-purple-50/50 dark:bg-purple-900/10 rounded-xl border border-purple-100 dark:border-purple-800">
|
|
392
|
-
<div className="flex items-center gap-2 mb-3">
|
|
393
|
-
<ArrowUpCircle size={16} className="text-purple-600" />
|
|
394
|
-
<span className="text-sm font-semibold text-purple-900 dark:text-purple-100">Outputs</span>
|
|
395
|
-
<Badge variant="secondary" className="ml-auto text-xs">{stepData.outputs.length}</Badge>
|
|
514
|
+
{
|
|
515
|
+
(stepData.inputs?.length > 0 || stepData.outputs?.length > 0) && (
|
|
516
|
+
<div className="space-y-4">
|
|
517
|
+
<h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide flex items-center gap-2">
|
|
518
|
+
<Database size={16} />
|
|
519
|
+
Data Flow
|
|
520
|
+
</h5>
|
|
521
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
522
|
+
{stepData.inputs?.length > 0 && (
|
|
523
|
+
<div className="p-4 bg-blue-50/50 dark:bg-blue-900/10 rounded-xl border border-blue-100 dark:border-blue-800">
|
|
524
|
+
<div className="flex items-center gap-2 mb-3">
|
|
525
|
+
<ArrowDownCircle size={16} className="text-blue-600" />
|
|
526
|
+
<span className="text-sm font-semibold text-blue-900 dark:text-blue-100">Inputs</span>
|
|
527
|
+
<Badge variant="secondary" className="ml-auto text-xs">{stepData.inputs.length}</Badge>
|
|
528
|
+
</div>
|
|
529
|
+
<div className="space-y-1.5">
|
|
530
|
+
{stepData.inputs.map((input, idx) => (
|
|
531
|
+
<div key={idx} className="flex items-center gap-2 p-2 bg-white dark:bg-slate-800 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
|
532
|
+
<Database size={12} className="text-blue-500 flex-shrink-0" />
|
|
533
|
+
<span className="text-sm font-mono text-slate-700 dark:text-slate-200 truncate">{input}</span>
|
|
534
|
+
</div>
|
|
535
|
+
))}
|
|
536
|
+
</div>
|
|
396
537
|
</div>
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
</
|
|
403
|
-
|
|
538
|
+
)}
|
|
539
|
+
{stepData.outputs?.length > 0 && (
|
|
540
|
+
<div className="p-4 bg-purple-50/50 dark:bg-purple-900/10 rounded-xl border border-purple-100 dark:border-purple-800">
|
|
541
|
+
<div className="flex items-center gap-2 mb-3">
|
|
542
|
+
<ArrowUpCircle size={16} className="text-purple-600" />
|
|
543
|
+
<span className="text-sm font-semibold text-purple-900 dark:text-purple-100">Outputs</span>
|
|
544
|
+
<Badge variant="secondary" className="ml-auto text-xs">{stepData.outputs.length}</Badge>
|
|
545
|
+
</div>
|
|
546
|
+
<div className="space-y-1.5">
|
|
547
|
+
{stepData.outputs.map((output, idx) => (
|
|
548
|
+
<div key={idx} className="flex items-center gap-2 p-2 bg-white dark:bg-slate-800 rounded-lg border border-purple-100 dark:border-purple-800/50">
|
|
549
|
+
<Box size={12} className="text-purple-500 flex-shrink-0" />
|
|
550
|
+
<span className="text-sm font-mono text-slate-700 dark:text-slate-200 truncate">{output}</span>
|
|
551
|
+
</div>
|
|
552
|
+
))}
|
|
553
|
+
</div>
|
|
404
554
|
</div>
|
|
405
|
-
|
|
406
|
-
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
407
557
|
</div>
|
|
408
|
-
|
|
409
|
-
|
|
558
|
+
)
|
|
559
|
+
}
|
|
410
560
|
|
|
411
561
|
{/* Tags/Metadata */}
|
|
412
|
-
{
|
|
413
|
-
|
|
414
|
-
<
|
|
415
|
-
<
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
<div className="
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
562
|
+
{
|
|
563
|
+
stepData.tags && Object.keys(stepData.tags).length > 0 && (
|
|
564
|
+
<div>
|
|
565
|
+
<h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
566
|
+
<Tag size={16} />
|
|
567
|
+
Metadata
|
|
568
|
+
</h5>
|
|
569
|
+
<div className="grid grid-cols-2 gap-3">
|
|
570
|
+
{Object.entries(stepData.tags).map(([key, value]) => (
|
|
571
|
+
<div key={key} className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
|
|
572
|
+
<div className="text-xs text-slate-500 dark:text-slate-400 mb-1">{key}</div>
|
|
573
|
+
<div className="text-sm font-mono font-medium text-slate-900 dark:text-white truncate">{String(value)}</div>
|
|
574
|
+
</div>
|
|
575
|
+
))}
|
|
576
|
+
</div>
|
|
425
577
|
</div>
|
|
426
|
-
|
|
427
|
-
|
|
578
|
+
)
|
|
579
|
+
}
|
|
428
580
|
|
|
429
581
|
{/* Cached Badge */}
|
|
430
|
-
{
|
|
431
|
-
|
|
432
|
-
<div className="
|
|
433
|
-
<
|
|
434
|
-
|
|
435
|
-
<
|
|
436
|
-
|
|
582
|
+
{
|
|
583
|
+
stepData.cached && (
|
|
584
|
+
<div className="p-4 bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 rounded-xl border-2 border-blue-200 dark:border-blue-800">
|
|
585
|
+
<div className="flex items-center gap-3">
|
|
586
|
+
<Zap size={24} className="text-blue-600" />
|
|
587
|
+
<div>
|
|
588
|
+
<h6 className="font-bold text-blue-900 dark:text-blue-100">Cached Result</h6>
|
|
589
|
+
<p className="text-sm text-blue-700 dark:text-blue-300">This step used cached results from a previous run</p>
|
|
590
|
+
</div>
|
|
437
591
|
</div>
|
|
438
592
|
</div>
|
|
439
|
-
|
|
440
|
-
|
|
593
|
+
)
|
|
594
|
+
}
|
|
441
595
|
|
|
442
596
|
{/* Error */}
|
|
443
|
-
{
|
|
444
|
-
|
|
445
|
-
<
|
|
446
|
-
<
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
<
|
|
451
|
-
|
|
452
|
-
|
|
597
|
+
{
|
|
598
|
+
stepData.error && (
|
|
599
|
+
<div>
|
|
600
|
+
<h5 className="text-sm font-bold text-rose-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
601
|
+
<AlertCircle size={16} />
|
|
602
|
+
Error Details
|
|
603
|
+
</h5>
|
|
604
|
+
<div className="p-4 bg-rose-50 dark:bg-rose-900/20 rounded-xl border-2 border-rose-200 dark:border-rose-800">
|
|
605
|
+
<pre className="text-sm font-mono text-rose-700 dark:text-rose-300 whitespace-pre-wrap overflow-x-auto">
|
|
606
|
+
{stepData.error}
|
|
607
|
+
</pre>
|
|
608
|
+
</div>
|
|
453
609
|
</div>
|
|
454
|
-
|
|
455
|
-
|
|
610
|
+
)
|
|
611
|
+
}
|
|
456
612
|
|
|
457
613
|
{/* Metrics with Visualization */}
|
|
458
|
-
{
|
|
459
|
-
|
|
460
|
-
<
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
614
|
+
{
|
|
615
|
+
metrics?.length > 0 && (
|
|
616
|
+
<div>
|
|
617
|
+
<h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
618
|
+
<TrendingUp size={16} />
|
|
619
|
+
Performance Metrics
|
|
620
|
+
</h5>
|
|
621
|
+
<div className="grid grid-cols-2 gap-3">
|
|
622
|
+
{metrics.map((metric, idx) => (
|
|
623
|
+
<MetricCard key={idx} metric={metric} />
|
|
624
|
+
))}
|
|
625
|
+
</div>
|
|
468
626
|
</div>
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
</div>
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
</div >
|
|
472
630
|
);
|
|
473
631
|
}
|
|
474
632
|
|
|
@@ -477,12 +635,12 @@ function MetricCard({ metric }) {
|
|
|
477
635
|
const displayValue = isNumeric ? metric.value.toFixed(4) : metric.value;
|
|
478
636
|
|
|
479
637
|
return (
|
|
480
|
-
<div className="p-3 bg-gradient-to-br from-slate-50 to-white rounded-lg border border-slate-200 hover:border-primary-300 transition-all group">
|
|
481
|
-
<span className="text-xs text-slate-500 block truncate mb-1" title={metric.name}>
|
|
638
|
+
<div className="p-3 bg-gradient-to-br from-slate-50 to-white dark:from-slate-800 dark:to-slate-900 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-primary-300 dark:hover:border-primary-700 transition-all group">
|
|
639
|
+
<span className="text-xs text-slate-500 dark:text-slate-400 block truncate mb-1" title={metric.name}>
|
|
482
640
|
{metric.name}
|
|
483
641
|
</span>
|
|
484
642
|
<div className="flex items-baseline gap-2">
|
|
485
|
-
<span className="text-xl font-mono font-bold text-slate-900 group-hover:text-primary-600 transition-colors">
|
|
643
|
+
<span className="text-xl font-mono font-bold text-slate-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
|
|
486
644
|
{displayValue}
|
|
487
645
|
</span>
|
|
488
646
|
{isNumeric && metric.value > 0 && (
|
|
@@ -606,9 +764,12 @@ function ArtifactModal({ artifact, onClose }) {
|
|
|
606
764
|
{/* Content */}
|
|
607
765
|
<div className="p-6 overflow-y-auto max-h-[60vh]">
|
|
608
766
|
<div className="space-y-4">
|
|
609
|
-
{/*
|
|
767
|
+
{/* Rich Viewer */}
|
|
768
|
+
<ArtifactViewer artifact={artifact} />
|
|
769
|
+
|
|
770
|
+
{/* Properties (collapsible or below) */}
|
|
610
771
|
{artifact.properties && Object.keys(artifact.properties).length > 0 && (
|
|
611
|
-
<div>
|
|
772
|
+
<div className="mt-6 pt-6 border-t border-slate-100">
|
|
612
773
|
<h4 className="text-sm font-semibold text-slate-700 mb-3">Properties</h4>
|
|
613
774
|
<div className="grid grid-cols-2 gap-3">
|
|
614
775
|
{Object.entries(artifact.properties).map(([key, value]) => (
|
|
@@ -623,18 +784,8 @@ function ArtifactModal({ artifact, onClose }) {
|
|
|
623
784
|
</div>
|
|
624
785
|
)}
|
|
625
786
|
|
|
626
|
-
{/* Value Preview */}
|
|
627
|
-
{artifact.value && (
|
|
628
|
-
<div>
|
|
629
|
-
<h4 className="text-sm font-semibold text-slate-700 mb-3">Value Preview</h4>
|
|
630
|
-
<pre className="p-4 bg-slate-900 text-slate-100 rounded-lg text-xs font-mono overflow-x-auto">
|
|
631
|
-
{artifact.value}
|
|
632
|
-
</pre>
|
|
633
|
-
</div>
|
|
634
|
-
)}
|
|
635
|
-
|
|
636
787
|
{/* Metadata */}
|
|
637
|
-
<div>
|
|
788
|
+
<div className="mt-6">
|
|
638
789
|
<h4 className="text-sm font-semibold text-slate-700 mb-3">Metadata</h4>
|
|
639
790
|
<div className="space-y-2 text-sm">
|
|
640
791
|
<div className="flex justify-between">
|
|
@@ -667,84 +818,148 @@ function ArtifactModal({ artifact, onClose }) {
|
|
|
667
818
|
);
|
|
668
819
|
}
|
|
669
820
|
|
|
670
|
-
function
|
|
671
|
-
const [
|
|
672
|
-
const [
|
|
673
|
-
const [
|
|
821
|
+
function LogsViewer({ runId, stepName, isRunning, maxHeight = "max-h-96", minimal = false }) {
|
|
822
|
+
const [logs, setLogs] = useState('');
|
|
823
|
+
const [loading, setLoading] = useState(true);
|
|
824
|
+
const [useWebSocket, setUseWebSocket] = useState(true);
|
|
825
|
+
const wsRef = React.useRef(null);
|
|
826
|
+
const logsEndRef = React.useRef(null);
|
|
674
827
|
|
|
828
|
+
// Auto-scroll to bottom when new logs arrive (only if not viewing history?)
|
|
829
|
+
// Actually always auto-scroll for now unless user scrolled up (advanced)
|
|
675
830
|
useEffect(() => {
|
|
676
|
-
if (
|
|
677
|
-
|
|
678
|
-
.then(res => res.json())
|
|
679
|
-
.then(data => setProjects(data))
|
|
680
|
-
.catch(err => console.error('Failed to load projects:', err));
|
|
831
|
+
if (logsEndRef.current) {
|
|
832
|
+
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
681
833
|
}
|
|
682
|
-
}, [
|
|
834
|
+
}, [logs]);
|
|
683
835
|
|
|
684
|
-
|
|
685
|
-
setUpdating(true);
|
|
686
|
-
try {
|
|
687
|
-
await fetch(`/api/runs/${runId}/project`, {
|
|
688
|
-
method: 'PUT',
|
|
689
|
-
headers: { 'Content-Type': 'application/json' },
|
|
690
|
-
body: JSON.stringify({ project_name: projectName })
|
|
691
|
-
});
|
|
836
|
+
// ... (rest of logic same) ...
|
|
692
837
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
setTimeout(() => document.body.removeChild(toast), 3000);
|
|
838
|
+
// Reset logs when step changes
|
|
839
|
+
useEffect(() => {
|
|
840
|
+
setLogs('');
|
|
841
|
+
setLoading(true);
|
|
698
842
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
} finally {
|
|
704
|
-
setUpdating(false);
|
|
843
|
+
// Close existing WebSocket
|
|
844
|
+
if (wsRef.current) {
|
|
845
|
+
wsRef.current.close();
|
|
846
|
+
wsRef.current = null;
|
|
705
847
|
}
|
|
706
|
-
};
|
|
848
|
+
}, [stepName]);
|
|
707
849
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
850
|
+
// Fetch initial logs and set up WebSocket
|
|
851
|
+
useEffect(() => {
|
|
852
|
+
let isMounted = true;
|
|
853
|
+
|
|
854
|
+
// Fetch initial logs
|
|
855
|
+
const fetchInitialLogs = async () => {
|
|
856
|
+
try {
|
|
857
|
+
const res = await fetchApi(`/api/runs/${runId}/steps/${stepName}/logs`);
|
|
858
|
+
const data = await res.json();
|
|
859
|
+
if (isMounted && data.logs) {
|
|
860
|
+
setLogs(data.logs);
|
|
861
|
+
}
|
|
862
|
+
} catch (error) {
|
|
863
|
+
console.error('Failed to fetch logs:', error);
|
|
864
|
+
} finally {
|
|
865
|
+
if (isMounted) setLoading(false);
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
fetchInitialLogs();
|
|
870
|
+
|
|
871
|
+
// Set up WebSocket for live updates if running
|
|
872
|
+
if (isRunning && useWebSocket) {
|
|
873
|
+
try {
|
|
874
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
875
|
+
const wsUrl = `${wsProtocol}//${window.location.host}/ws/runs/${runId}/steps/${stepName}/logs`;
|
|
876
|
+
const ws = new WebSocket(wsUrl);
|
|
877
|
+
|
|
878
|
+
ws.onopen = () => {
|
|
879
|
+
console.log('WebSocket connected for logs');
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
ws.onmessage = (event) => {
|
|
883
|
+
try {
|
|
884
|
+
const data = JSON.parse(event.data);
|
|
885
|
+
if (data.type === 'log' && isMounted) {
|
|
886
|
+
setLogs(prev => prev + data.content + '\n');
|
|
887
|
+
}
|
|
888
|
+
} catch (e) {
|
|
889
|
+
// Plain text message
|
|
890
|
+
if (isMounted && event.data !== 'pong') {
|
|
891
|
+
setLogs(prev => prev + event.data + '\n');
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
ws.onerror = () => {
|
|
897
|
+
console.log('WebSocket error, falling back to polling');
|
|
898
|
+
setUseWebSocket(false);
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
ws.onclose = () => {
|
|
902
|
+
console.log('WebSocket closed');
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
wsRef.current = ws;
|
|
906
|
+
} catch (error) {
|
|
907
|
+
console.error('Failed to create WebSocket:', error);
|
|
908
|
+
setUseWebSocket(false);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Fallback to polling if WebSocket not available
|
|
913
|
+
let interval;
|
|
914
|
+
if (isRunning && !useWebSocket) {
|
|
915
|
+
let currentOffset = 0;
|
|
916
|
+
interval = setInterval(async () => {
|
|
917
|
+
try {
|
|
918
|
+
const res = await fetchApi(`/api/runs/${runId}/steps/${stepName}/logs?offset=${currentOffset}`);
|
|
919
|
+
const data = await res.json();
|
|
920
|
+
if (isMounted && data.logs) {
|
|
921
|
+
setLogs(prev => prev + data.logs);
|
|
922
|
+
currentOffset = data.offset;
|
|
923
|
+
}
|
|
924
|
+
} catch (error) {
|
|
925
|
+
console.error('Failed to fetch logs:', error);
|
|
926
|
+
}
|
|
927
|
+
}, 2000);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return () => {
|
|
931
|
+
isMounted = false;
|
|
932
|
+
if (wsRef.current) {
|
|
933
|
+
wsRef.current.close();
|
|
934
|
+
wsRef.current = null;
|
|
935
|
+
}
|
|
936
|
+
if (interval) clearInterval(interval);
|
|
937
|
+
};
|
|
938
|
+
}, [runId, stepName, isRunning, useWebSocket]);
|
|
939
|
+
|
|
940
|
+
if (loading && !logs) {
|
|
941
|
+
return (
|
|
942
|
+
<div className="flex items-center justify-center h-40">
|
|
943
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
|
944
|
+
</div>
|
|
945
|
+
);
|
|
946
|
+
}
|
|
719
947
|
|
|
720
|
-
|
|
948
|
+
return (
|
|
949
|
+
<div className={`bg-slate-900 rounded-lg p-4 font-mono text-sm overflow-x-auto ${maxHeight} overflow-y-auto`}>
|
|
950
|
+
{logs ? (
|
|
721
951
|
<>
|
|
722
|
-
<
|
|
723
|
-
<div
|
|
724
|
-
<div className="p-2 border-b border-slate-100 dark:border-slate-700">
|
|
725
|
-
<span className="text-xs font-semibold text-slate-500 px-2">Select Project</span>
|
|
726
|
-
</div>
|
|
727
|
-
<div className="max-h-64 overflow-y-auto p-1">
|
|
728
|
-
{projects.length > 0 ? (
|
|
729
|
-
projects.map(p => (
|
|
730
|
-
<button
|
|
731
|
-
key={p.name}
|
|
732
|
-
onClick={() => handleSelectProject(p.name)}
|
|
733
|
-
disabled={updating}
|
|
734
|
-
className={`w-full text-left px-3 py-2 text-sm rounded-lg transition-colors ${p.name === currentProject
|
|
735
|
-
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 font-medium'
|
|
736
|
-
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'
|
|
737
|
-
}`}
|
|
738
|
-
>
|
|
739
|
-
{p.name} {p.name === currentProject && '✓'}
|
|
740
|
-
</button>
|
|
741
|
-
))
|
|
742
|
-
) : (
|
|
743
|
-
<div className="px-3 py-2 text-sm text-slate-400">No projects found</div>
|
|
744
|
-
)}
|
|
745
|
-
</div>
|
|
746
|
-
</div>
|
|
952
|
+
<pre className="text-green-400 whitespace-pre-wrap break-words">{minimal ? logs.split('\n').slice(-10).join('\n') : logs}</pre>
|
|
953
|
+
<div ref={logsEndRef} />
|
|
747
954
|
</>
|
|
955
|
+
) : (
|
|
956
|
+
<div className="text-slate-500 italic">No logs available for this step.</div>
|
|
957
|
+
)}
|
|
958
|
+
{!minimal && isRunning && (
|
|
959
|
+
<div className="mt-2 text-amber-500 flex items-center gap-2 animate-pulse">
|
|
960
|
+
<Activity size={14} />
|
|
961
|
+
{useWebSocket ? 'Live streaming...' : 'Polling for logs...'}
|
|
962
|
+
</div>
|
|
748
963
|
)}
|
|
749
964
|
</div>
|
|
750
965
|
);
|