flowyml 1.3.0__py3-none-any.whl → 1.5.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.
Files changed (52) hide show
  1. flowyml/core/execution_status.py +1 -0
  2. flowyml/core/executor.py +175 -3
  3. flowyml/core/observability.py +7 -7
  4. flowyml/core/resources.py +12 -12
  5. flowyml/core/retry_policy.py +2 -2
  6. flowyml/core/scheduler.py +9 -9
  7. flowyml/core/scheduler_config.py +2 -3
  8. flowyml/core/submission_result.py +4 -4
  9. flowyml/stacks/bridge.py +9 -9
  10. flowyml/stacks/plugins.py +2 -2
  11. flowyml/stacks/registry.py +21 -0
  12. flowyml/storage/materializers/base.py +33 -0
  13. flowyml/storage/metadata.py +3 -1042
  14. flowyml/storage/remote.py +590 -0
  15. flowyml/storage/sql.py +951 -0
  16. flowyml/ui/backend/dependencies.py +28 -0
  17. flowyml/ui/backend/main.py +4 -79
  18. flowyml/ui/backend/routers/assets.py +170 -9
  19. flowyml/ui/backend/routers/client.py +6 -6
  20. flowyml/ui/backend/routers/execution.py +2 -2
  21. flowyml/ui/backend/routers/experiments.py +53 -6
  22. flowyml/ui/backend/routers/metrics.py +23 -68
  23. flowyml/ui/backend/routers/pipelines.py +19 -10
  24. flowyml/ui/backend/routers/runs.py +287 -9
  25. flowyml/ui/backend/routers/schedules.py +5 -21
  26. flowyml/ui/backend/routers/stats.py +14 -0
  27. flowyml/ui/backend/routers/traces.py +37 -53
  28. flowyml/ui/backend/routers/websocket.py +121 -0
  29. flowyml/ui/frontend/dist/assets/index-CBUXOWze.css +1 -0
  30. flowyml/ui/frontend/dist/assets/index-DF8dJaFL.js +629 -0
  31. flowyml/ui/frontend/dist/index.html +2 -2
  32. flowyml/ui/frontend/package-lock.json +289 -0
  33. flowyml/ui/frontend/package.json +1 -0
  34. flowyml/ui/frontend/src/app/compare/page.jsx +213 -0
  35. flowyml/ui/frontend/src/app/experiments/compare/page.jsx +289 -0
  36. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -1
  37. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +418 -203
  38. flowyml/ui/frontend/src/app/runs/page.jsx +64 -3
  39. flowyml/ui/frontend/src/app/settings/page.jsx +1 -1
  40. flowyml/ui/frontend/src/app/tokens/page.jsx +8 -6
  41. flowyml/ui/frontend/src/components/ArtifactViewer.jsx +159 -0
  42. flowyml/ui/frontend/src/components/NavigationTree.jsx +26 -9
  43. flowyml/ui/frontend/src/components/PipelineGraph.jsx +26 -24
  44. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +42 -14
  45. flowyml/ui/frontend/src/router/index.jsx +4 -0
  46. {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/METADATA +3 -1
  47. {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/RECORD +50 -42
  48. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +0 -1
  49. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +0 -592
  50. {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/WHEEL +0 -0
  51. {flowyml-1.3.0.dist-info → flowyml-1.5.0.dist-info}/entry_points.txt +0 -0
  52. {flowyml-1.3.0.dist-info → flowyml-1.5.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
- <SimpleProjectSelector runId={run.run_id} currentProject={run.project} />
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 stepData={selectedStepData} metrics={selectedStepMetrics} />
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-b-2 border-primary-600'
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.success ? (
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
- {(stepData.inputs?.length > 0 || stepData.outputs?.length > 0) && (
367
- <div className="space-y-4">
368
- <h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide flex items-center gap-2">
369
- <Database size={16} />
370
- Data Flow
371
- </h5>
372
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
373
- {stepData.inputs?.length > 0 && (
374
- <div className="p-4 bg-blue-50/50 dark:bg-blue-900/10 rounded-xl border border-blue-100 dark:border-blue-800">
375
- <div className="flex items-center gap-2 mb-3">
376
- <ArrowDownCircle size={16} className="text-blue-600" />
377
- <span className="text-sm font-semibold text-blue-900 dark:text-blue-100">Inputs</span>
378
- <Badge variant="secondary" className="ml-auto text-xs">{stepData.inputs.length}</Badge>
379
- </div>
380
- <div className="space-y-1.5">
381
- {stepData.inputs.map((input, idx) => (
382
- <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">
383
- <Database size={12} className="text-blue-500 flex-shrink-0" />
384
- <span className="text-sm font-mono text-slate-700 dark:text-slate-200 truncate">{input}</span>
385
- </div>
386
- ))}
387
- </div>
388
- </div>
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
- <div className="space-y-1.5">
398
- {stepData.outputs.map((output, idx) => (
399
- <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">
400
- <Box size={12} className="text-purple-500 flex-shrink-0" />
401
- <span className="text-sm font-mono text-slate-700 dark:text-slate-200 truncate">{output}</span>
402
- </div>
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
- </div>
406
- )}
555
+ )}
556
+ </div>
407
557
  </div>
408
- </div>
409
- )}
558
+ )
559
+ }
410
560
 
411
561
  {/* Tags/Metadata */}
412
- {stepData.tags && Object.keys(stepData.tags).length > 0 && (
413
- <div>
414
- <h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
415
- <Tag size={16} />
416
- Metadata
417
- </h5>
418
- <div className="grid grid-cols-2 gap-3">
419
- {Object.entries(stepData.tags).map(([key, value]) => (
420
- <div key={key} className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
421
- <div className="text-xs text-slate-500 dark:text-slate-400 mb-1">{key}</div>
422
- <div className="text-sm font-mono font-medium text-slate-900 dark:text-white truncate">{String(value)}</div>
423
- </div>
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
- </div>
427
- )}
578
+ )
579
+ }
428
580
 
429
581
  {/* Cached Badge */}
430
- {stepData.cached && (
431
- <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">
432
- <div className="flex items-center gap-3">
433
- <Zap size={24} className="text-blue-600" />
434
- <div>
435
- <h6 className="font-bold text-blue-900 dark:text-blue-100">Cached Result</h6>
436
- <p className="text-sm text-blue-700 dark:text-blue-300">This step used cached results from a previous run</p>
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
- </div>
440
- )}
593
+ )
594
+ }
441
595
 
442
596
  {/* Error */}
443
- {stepData.error && (
444
- <div>
445
- <h5 className="text-sm font-bold text-rose-700 uppercase tracking-wide mb-3 flex items-center gap-2">
446
- <AlertCircle size={16} />
447
- Error Details
448
- </h5>
449
- <div className="p-4 bg-rose-50 dark:bg-rose-900/20 rounded-xl border-2 border-rose-200 dark:border-rose-800">
450
- <pre className="text-sm font-mono text-rose-700 dark:text-rose-300 whitespace-pre-wrap overflow-x-auto">
451
- {stepData.error}
452
- </pre>
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
- </div>
455
- )}
610
+ )
611
+ }
456
612
 
457
613
  {/* Metrics with Visualization */}
458
- {metrics?.length > 0 && (
459
- <div>
460
- <h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
461
- <TrendingUp size={16} />
462
- Performance Metrics
463
- </h5>
464
- <div className="grid grid-cols-2 gap-3">
465
- {metrics.map((metric, idx) => (
466
- <MetricCard key={idx} metric={metric} />
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
- </div>
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
- {/* Properties */}
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 SimpleProjectSelector({ runId, currentProject }) {
671
- const [isOpen, setIsOpen] = useState(false);
672
- const [projects, setProjects] = useState([]);
673
- const [updating, setUpdating] = useState(false);
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 (isOpen) {
677
- fetch('/api/projects/')
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
- }, [isOpen]);
834
+ }, [logs]);
683
835
 
684
- const handleSelectProject = async (projectName) => {
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
- const toast = document.createElement('div');
694
- toast.className = 'fixed top-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 bg-green-500 text-white';
695
- toast.textContent = `Run added to project ${projectName}`;
696
- document.body.appendChild(toast);
697
- setTimeout(() => document.body.removeChild(toast), 3000);
838
+ // Reset logs when step changes
839
+ useEffect(() => {
840
+ setLogs('');
841
+ setLoading(true);
698
842
 
699
- setIsOpen(false);
700
- setTimeout(() => window.location.reload(), 500);
701
- } catch (error) {
702
- console.error('Failed to update project:', error);
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
- return (
709
- <div className="relative">
710
- <button
711
- onClick={() => setIsOpen(!isOpen)}
712
- disabled={updating}
713
- className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
714
- title={currentProject ? `Current project: ${currentProject}` : 'Add to project'}
715
- >
716
- <FolderPlus size={16} />
717
- {currentProject || 'Add to Project'}
718
- </button>
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
- {isOpen && (
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
- <div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
723
- <div className="absolute right-0 mt-2 w-56 bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-20">
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
  );