flowyml 1.2.0__py3-none-any.whl → 1.4.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 (104) hide show
  1. flowyml/__init__.py +3 -0
  2. flowyml/assets/base.py +10 -0
  3. flowyml/assets/metrics.py +6 -0
  4. flowyml/cli/main.py +108 -2
  5. flowyml/cli/run.py +9 -2
  6. flowyml/core/execution_status.py +52 -0
  7. flowyml/core/hooks.py +106 -0
  8. flowyml/core/observability.py +210 -0
  9. flowyml/core/orchestrator.py +274 -0
  10. flowyml/core/pipeline.py +193 -231
  11. flowyml/core/project.py +34 -2
  12. flowyml/core/remote_orchestrator.py +109 -0
  13. flowyml/core/resources.py +34 -17
  14. flowyml/core/retry_policy.py +80 -0
  15. flowyml/core/scheduler.py +9 -9
  16. flowyml/core/scheduler_config.py +2 -3
  17. flowyml/core/step.py +18 -1
  18. flowyml/core/submission_result.py +53 -0
  19. flowyml/integrations/keras.py +95 -22
  20. flowyml/monitoring/alerts.py +2 -2
  21. flowyml/stacks/__init__.py +15 -0
  22. flowyml/stacks/aws.py +599 -0
  23. flowyml/stacks/azure.py +295 -0
  24. flowyml/stacks/bridge.py +9 -9
  25. flowyml/stacks/components.py +24 -2
  26. flowyml/stacks/gcp.py +158 -11
  27. flowyml/stacks/local.py +5 -0
  28. flowyml/stacks/plugins.py +2 -2
  29. flowyml/stacks/registry.py +21 -0
  30. flowyml/storage/artifacts.py +15 -5
  31. flowyml/storage/materializers/__init__.py +2 -0
  32. flowyml/storage/materializers/base.py +33 -0
  33. flowyml/storage/materializers/cloudpickle.py +74 -0
  34. flowyml/storage/metadata.py +3 -881
  35. flowyml/storage/remote.py +590 -0
  36. flowyml/storage/sql.py +911 -0
  37. flowyml/ui/backend/dependencies.py +28 -0
  38. flowyml/ui/backend/main.py +43 -80
  39. flowyml/ui/backend/routers/assets.py +483 -17
  40. flowyml/ui/backend/routers/client.py +46 -0
  41. flowyml/ui/backend/routers/execution.py +13 -2
  42. flowyml/ui/backend/routers/experiments.py +97 -14
  43. flowyml/ui/backend/routers/metrics.py +168 -0
  44. flowyml/ui/backend/routers/pipelines.py +77 -12
  45. flowyml/ui/backend/routers/projects.py +33 -7
  46. flowyml/ui/backend/routers/runs.py +221 -12
  47. flowyml/ui/backend/routers/schedules.py +5 -21
  48. flowyml/ui/backend/routers/stats.py +14 -0
  49. flowyml/ui/backend/routers/traces.py +37 -53
  50. flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
  51. flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
  52. flowyml/ui/frontend/dist/index.html +2 -2
  53. flowyml/ui/frontend/src/App.jsx +4 -1
  54. flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
  55. flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
  56. flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
  57. flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
  58. flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
  59. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
  60. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
  61. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
  62. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
  63. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
  64. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
  65. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
  66. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
  67. flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
  68. flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
  69. flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
  70. flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
  71. flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
  72. flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
  73. flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
  74. flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
  75. flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
  76. flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
  77. flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
  78. flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
  79. flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
  80. flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
  81. flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
  82. flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
  83. flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
  84. flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
  85. flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
  86. flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
  87. flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
  88. flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
  89. flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
  90. flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
  91. flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
  92. flowyml/ui/frontend/src/router/index.jsx +4 -0
  93. flowyml/ui/frontend/src/utils/date.js +10 -0
  94. flowyml/ui/frontend/src/utils/downloads.js +11 -0
  95. flowyml/utils/config.py +6 -0
  96. flowyml/utils/stack_config.py +45 -3
  97. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/METADATA +44 -4
  98. flowyml-1.4.0.dist-info/RECORD +200 -0
  99. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/licenses/LICENSE +1 -1
  100. flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
  101. flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
  102. flowyml-1.2.0.dist-info/RECORD +0 -159
  103. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/WHEEL +0 -0
  104. {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,253 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { fetchApi } from '../../../../utils/api';
3
+ import {
4
+ LineChart,
5
+ Line,
6
+ BarChart,
7
+ Bar,
8
+ XAxis,
9
+ YAxis,
10
+ CartesianGrid,
11
+ Tooltip,
12
+ Legend,
13
+ ResponsiveContainer,
14
+ ReferenceLine
15
+ } from 'recharts';
16
+ import { format } from 'date-fns';
17
+ import { Box, Activity, Clock, Tag } from 'lucide-react';
18
+ import { Card } from '../../../../components/ui/Card';
19
+ import { Badge } from '../../../../components/ui/Badge';
20
+
21
+ export function ProjectMetricsPanel({ projectId }) {
22
+ const [metrics, setMetrics] = useState([]);
23
+ const [loading, setLoading] = useState(true);
24
+ const [error, setError] = useState(null);
25
+
26
+ useEffect(() => {
27
+ const fetchMetrics = async () => {
28
+ setLoading(true);
29
+ try {
30
+ const resp = await fetchApi(`/api/projects/${projectId}/metrics?limit=500`);
31
+ const data = await resp.json();
32
+ setMetrics(Array.isArray(data?.metrics) ? data.metrics : []);
33
+ } catch (err) {
34
+ console.error('Failed to load project metrics', err);
35
+ setError('Unable to load metrics');
36
+ } finally {
37
+ setLoading(false);
38
+ }
39
+ };
40
+
41
+ if (projectId) {
42
+ fetchMetrics();
43
+ }
44
+ }, [projectId]);
45
+
46
+ if (loading) return <LoadingState />;
47
+ if (error) return <ErrorState error={error} />;
48
+ if (!metrics.length) return <EmptyState />;
49
+
50
+ // Group metrics by model
51
+ const modelGroups = metrics.reduce((acc, curr) => {
52
+ if (!acc[curr.model_name]) {
53
+ acc[curr.model_name] = [];
54
+ }
55
+ acc[curr.model_name].push(curr);
56
+ return acc;
57
+ }, {});
58
+
59
+ return (
60
+ <div className="space-y-8">
61
+ {Object.entries(modelGroups).map(([modelName, modelMetrics]) => (
62
+ <ModelMetricsDashboard
63
+ key={modelName}
64
+ modelName={modelName}
65
+ metrics={modelMetrics}
66
+ />
67
+ ))}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ function ModelMetricsDashboard({ modelName, metrics }) {
73
+ // Sort by date
74
+ const sortedMetrics = [...metrics].sort((a, b) =>
75
+ new Date(a.created_at) - new Date(b.created_at)
76
+ );
77
+
78
+ // Get latest metrics for summary
79
+ const latestMetricEntry = sortedMetrics[sortedMetrics.length - 1];
80
+
81
+ // Prepare data for charts
82
+ // We need to pivot the data: one entry per timestamp, with keys for each metric
83
+ // The API returns one row per metric name (e.g. "accuracy", "f1_score") per timestamp?
84
+ // Wait, let's check seed data.
85
+ // project_store.log_model_metrics calls save_metrics which saves individual rows?
86
+ // Or does it save a JSON blob?
87
+ // The seed data calls log_model_metrics with a dict of metrics.
88
+ // The API likely returns flattened rows or the logged object.
89
+ // Let's assume the API returns what we saw in the previous file:
90
+ // { model_name, metric_name, metric_value, created_at, ... }
91
+
92
+ // So we need to group by timestamp to form a "run" or "log event"
93
+ const historyData = sortedMetrics.reduce((acc, curr) => {
94
+ const timeKey = curr.created_at;
95
+ if (!acc[timeKey]) {
96
+ acc[timeKey] = {
97
+ timestamp: timeKey,
98
+ displayDate: format(new Date(timeKey), 'MMM d HH:mm'),
99
+ tags: curr.tags,
100
+ run_id: curr.run_id,
101
+ environment: curr.environment
102
+ };
103
+ }
104
+ // Add metric value
105
+ acc[timeKey][curr.metric_name] = typeof curr.metric_value === 'number'
106
+ ? parseFloat(curr.metric_value.toFixed(4))
107
+ : curr.metric_value;
108
+ return acc;
109
+ }, {});
110
+
111
+ const chartData = Object.values(historyData);
112
+
113
+ // Identify metric names (keys) that are numbers
114
+ const metricNames = [...new Set(metrics.map(m => m.metric_name))];
115
+ const numericMetrics = metricNames.filter(name =>
116
+ chartData.some(d => typeof d[name] === 'number')
117
+ );
118
+
119
+ // Separate latency from score metrics (usually 0-1)
120
+ const scoreMetrics = numericMetrics.filter(name => !name.includes('latency') && !name.includes('loss'));
121
+ const latencyMetrics = numericMetrics.filter(name => name.includes('latency'));
122
+
123
+ return (
124
+ <Card className="overflow-hidden border-slate-200 dark:border-slate-800">
125
+ <div className="p-4 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/50 flex items-center justify-between">
126
+ <div className="flex items-center gap-3">
127
+ <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
128
+ <Box className="w-5 h-5 text-purple-600 dark:text-purple-400" />
129
+ </div>
130
+ <div>
131
+ <h3 className="font-semibold text-slate-900 dark:text-white">{modelName}</h3>
132
+ <div className="flex items-center gap-2 text-xs text-slate-500">
133
+ <Clock size={12} />
134
+ Last updated: {format(new Date(latestMetricEntry.created_at), 'MMM d, yyyy HH:mm')}
135
+ </div>
136
+ </div>
137
+ </div>
138
+ <div className="flex gap-2">
139
+ {latestMetricEntry.environment && (
140
+ <Badge variant="outline" className="bg-white dark:bg-slate-800">
141
+ {latestMetricEntry.environment}
142
+ </Badge>
143
+ )}
144
+ <Badge className="bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 border-0">
145
+ {chartData.length} logs
146
+ </Badge>
147
+ </div>
148
+ </div>
149
+
150
+ <div className="p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
151
+ {/* Latest Stats */}
152
+ <div className="space-y-4">
153
+ <h4 className="text-sm font-medium text-slate-500 uppercase tracking-wider">Latest Performance</h4>
154
+ <div className="grid grid-cols-2 gap-3">
155
+ {scoreMetrics.map(metric => (
156
+ <div key={metric} className="p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-800">
157
+ <p className="text-xs text-slate-500 mb-1 capitalize">{metric.replace(/_/g, ' ')}</p>
158
+ <p className="text-lg font-bold text-slate-900 dark:text-white">
159
+ {chartData[chartData.length - 1][metric]}
160
+ </p>
161
+ </div>
162
+ ))}
163
+ {latencyMetrics.map(metric => (
164
+ <div key={metric} className="p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-800">
165
+ <p className="text-xs text-slate-500 mb-1 capitalize">{metric.replace(/_/g, ' ')}</p>
166
+ <p className="text-lg font-bold text-slate-900 dark:text-white">
167
+ {chartData[chartData.length - 1][metric]} <span className="text-xs font-normal text-slate-400">ms</span>
168
+ </p>
169
+ </div>
170
+ ))}
171
+ </div>
172
+ </div>
173
+
174
+ {/* Charts */}
175
+ <div className="lg:col-span-2 space-y-6">
176
+ {scoreMetrics.length > 0 && (
177
+ <div className="h-[250px] w-full">
178
+ <h4 className="text-sm font-medium text-slate-500 uppercase tracking-wider mb-4">Score Trends</h4>
179
+ <ResponsiveContainer width="100%" height="100%">
180
+ <LineChart data={chartData}>
181
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
182
+ <XAxis
183
+ dataKey="displayDate"
184
+ stroke="#94a3b8"
185
+ fontSize={12}
186
+ tickLine={false}
187
+ axisLine={false}
188
+ />
189
+ <YAxis
190
+ domain={[0, 1]}
191
+ stroke="#94a3b8"
192
+ fontSize={12}
193
+ tickLine={false}
194
+ axisLine={false}
195
+ />
196
+ <Tooltip
197
+ contentStyle={{ backgroundColor: '#fff', borderRadius: '8px', border: '1px solid #e2e8f0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
198
+ itemStyle={{ fontSize: '12px' }}
199
+ />
200
+ <Legend />
201
+ {scoreMetrics.map((metric, idx) => (
202
+ <Line
203
+ key={metric}
204
+ type="monotone"
205
+ dataKey={metric}
206
+ stroke={COLORS[idx % COLORS.length]}
207
+ strokeWidth={2}
208
+ dot={{ r: 3, fill: COLORS[idx % COLORS.length] }}
209
+ activeDot={{ r: 5 }}
210
+ />
211
+ ))}
212
+ </LineChart>
213
+ </ResponsiveContainer>
214
+ </div>
215
+ )}
216
+ </div>
217
+ </div>
218
+ </Card>
219
+ );
220
+ }
221
+
222
+ const COLORS = ['#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#ec4899'];
223
+
224
+ function LoadingState() {
225
+ return (
226
+ <div className="h-64 flex items-center justify-center border border-dashed border-slate-200 dark:border-slate-700 rounded-xl">
227
+ <div className="flex flex-col items-center gap-3">
228
+ <Activity className="w-8 h-8 text-primary-500 animate-spin" />
229
+ <p className="text-slate-500 dark:text-slate-400">Loading metrics...</p>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function ErrorState({ error }) {
236
+ return (
237
+ <div className="h-64 flex items-center justify-center border border-rose-200 dark:border-rose-900 bg-rose-50 dark:bg-rose-900/10 rounded-xl">
238
+ <p className="text-rose-600 dark:text-rose-400">{error}</p>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ function EmptyState() {
244
+ return (
245
+ <div className="h-64 flex items-center justify-center border border-dashed border-slate-200 dark:border-slate-700 rounded-xl">
246
+ <div className="text-center">
247
+ <BarChart2 className="w-12 h-12 text-slate-300 mx-auto mb-3" />
248
+ <p className="text-slate-500 dark:text-slate-400">No metrics logged yet</p>
249
+ <p className="text-xs text-slate-400 mt-1">Run a pipeline with model evaluation to see metrics here</p>
250
+ </div>
251
+ </div>
252
+ );
253
+ }
@@ -0,0 +1,105 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { fetchApi } from '../../../../utils/api';
3
+ import { DataView } from '../../../../components/ui/DataView';
4
+ import { Link } from 'react-router-dom';
5
+ import { Activity, Clock } from 'lucide-react';
6
+ import { formatDate } from '../../../../utils/date';
7
+
8
+ export function ProjectPipelinesList({ projectId }) {
9
+ const [pipelines, setPipelines] = useState([]);
10
+ const [loading, setLoading] = useState(true);
11
+
12
+ useEffect(() => {
13
+ const fetchPipelines = async () => {
14
+ try {
15
+ // In a real scenario, we would filter by project ID
16
+ // For now, we'll fetch all and filter client-side or assume the API handles it
17
+ const response = await fetchApi(`/api/pipelines?project=${projectId}`);
18
+ const data = await response.json();
19
+ // API returns {pipelines: [...]}
20
+ setPipelines(Array.isArray(data?.pipelines) ? data.pipelines : []);
21
+ } catch (error) {
22
+ console.error('Failed to fetch pipelines:', error);
23
+ } finally {
24
+ setLoading(false);
25
+ }
26
+ };
27
+
28
+ if (projectId) {
29
+ fetchPipelines();
30
+ }
31
+ }, [projectId]);
32
+
33
+ const columns = [
34
+ {
35
+ header: 'Pipeline Name',
36
+ key: 'name',
37
+ render: (pipeline) => (
38
+ <Link to={`/pipelines/${pipeline.id || pipeline.name}`} className="flex items-center gap-3 group">
39
+ <div className="p-2 bg-blue-500/10 rounded-lg group-hover:bg-blue-500/20 transition-colors">
40
+ <Activity className="w-4 h-4 text-blue-500" />
41
+ </div>
42
+ <span className="font-medium text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
43
+ {pipeline.name}
44
+ </span>
45
+ </Link>
46
+ )
47
+ },
48
+ {
49
+ header: 'Version',
50
+ key: 'version',
51
+ render: (pipeline) => (
52
+ <span className="px-2 py-1 bg-slate-100 dark:bg-slate-700 rounded text-xs font-medium">
53
+ v{pipeline.version || '1.0'}
54
+ </span>
55
+ )
56
+ },
57
+ {
58
+ header: 'Created',
59
+ key: 'created',
60
+ render: (pipeline) => (
61
+ <span className="text-slate-500 text-sm">
62
+ {formatDate(pipeline.created)}
63
+ </span>
64
+ )
65
+ }
66
+ ];
67
+
68
+ return (
69
+ <DataView
70
+ items={pipelines}
71
+ loading={loading}
72
+ columns={columns}
73
+ initialView="table"
74
+ renderGrid={(pipeline) => (
75
+ <Link to={`/pipelines/${pipeline.id || pipeline.name}`} className="block">
76
+ <div className="group p-5 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 hover:border-blue-500/50 hover:shadow-md transition-all duration-300">
77
+ <div className="flex items-start justify-between mb-4">
78
+ <div className="p-2.5 bg-blue-50 dark:bg-blue-900/20 rounded-lg group-hover:bg-blue-100 dark:group-hover:bg-blue-900/40 transition-colors">
79
+ <Activity className="w-6 h-6 text-blue-600 dark:text-blue-400" />
80
+ </div>
81
+ <span className="px-2.5 py-1 bg-slate-100 dark:bg-slate-700 rounded-full text-xs font-medium text-slate-600 dark:text-slate-300">
82
+ v{pipeline.version || '1.0'}
83
+ </span>
84
+ </div>
85
+
86
+ <h3 className="font-semibold text-lg text-slate-900 dark:text-white mb-1 truncate" title={pipeline.name}>
87
+ {pipeline.name}
88
+ </h3>
89
+
90
+ <div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 mt-4 pt-4 border-t border-slate-100 dark:border-slate-700/50">
91
+ <Clock className="w-4 h-4" />
92
+ <span>Created {formatDate(pipeline.created)}</span>
93
+ </div>
94
+ </div>
95
+ </Link>
96
+ )}
97
+ emptyState={
98
+ <div className="text-center py-8">
99
+ <Activity className="w-10 h-10 mx-auto text-slate-300 mb-2" />
100
+ <p className="text-slate-500">No pipelines found for this project</p>
101
+ </div>
102
+ }
103
+ />
104
+ );
105
+ }
@@ -0,0 +1,189 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import ReactFlow, {
3
+ Background,
4
+ Controls,
5
+ useNodesState,
6
+ useEdgesState,
7
+ Position
8
+ } from 'reactflow';
9
+ import 'reactflow/dist/style.css';
10
+ import { fetchApi } from '../../../../utils/api';
11
+ import { useParams, useNavigate } from 'react-router-dom';
12
+
13
+ const nodeDefaults = {
14
+ sourcePosition: Position.Right,
15
+ targetPosition: Position.Left,
16
+ };
17
+
18
+ export function ProjectRelations() {
19
+ const { projectId } = useParams();
20
+ const navigate = useNavigate();
21
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
22
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
23
+ const [loading, setLoading] = useState(true);
24
+
25
+ const onNodeClick = useCallback((event, node) => {
26
+ if (node.id.startsWith('pipeline-')) {
27
+ // Extract pipeline name from ID "pipeline-name"
28
+ // In a real app, we might need the ID, but here we use name as ID often
29
+ // Assuming we navigate to pipeline details.
30
+ // Since we don't have a direct route for pipeline details yet (or do we?),
31
+ // let's assume /pipelines/:id.
32
+ // But wait, the ID in the graph is "pipeline-{name}".
33
+ // Let's just navigate to the pipelines tab for now or a specific pipeline page if it exists.
34
+ // The user asked for "possibility to go to details of pipeline or run".
35
+ // I'll assume routes /pipelines/:id and /runs/:id exist or will exist.
36
+ // For now, let's log it and try to navigate.
37
+ const pipelineName = node.data.label;
38
+ // We don't have the UUID here easily unless we store it in data.
39
+ // Let's store the full object in data.
40
+ }
41
+ }, [navigate]);
42
+
43
+ useEffect(() => {
44
+ const fetchData = async () => {
45
+ try {
46
+ if (!projectId) return;
47
+
48
+ const [pipelinesRes, runsRes, artifactsRes] = await Promise.all([
49
+ fetchApi(`/api/pipelines?project=${projectId}`),
50
+ fetchApi(`/api/runs?project=${projectId}`),
51
+ fetchApi(`/api/assets?project=${projectId}`)
52
+ ]);
53
+
54
+ const pipelinesData = await pipelinesRes.json();
55
+ const runsData = await runsRes.json();
56
+ const artifactsData = await artifactsRes.json();
57
+
58
+ const pipelines = Array.isArray(pipelinesData?.pipelines) ? pipelinesData.pipelines : [];
59
+ const runs = Array.isArray(runsData?.runs) ? runsData.runs : [];
60
+ const artifacts = Array.isArray(artifactsData?.artifacts) ? artifactsData.artifacts : [];
61
+
62
+ console.log("Relations Data:", { pipelines, runs, artifacts });
63
+
64
+ // Build graph
65
+ const newNodes = [];
66
+ const newEdges = [];
67
+
68
+ // Project Node
69
+ newNodes.push({
70
+ id: 'project',
71
+ type: 'input',
72
+ data: { label: 'Project' },
73
+ position: { x: 0, y: 300 },
74
+ ...nodeDefaults,
75
+ style: { background: '#3b82f6', color: 'white', border: 'none', borderRadius: '8px', padding: '10px', width: 150 }
76
+ });
77
+
78
+ if (pipelines.length === 0) {
79
+ // Add a placeholder node if no pipelines
80
+ newNodes.push({
81
+ id: 'no-pipelines',
82
+ data: { label: 'No Pipelines Found' },
83
+ position: { x: 250, y: 300 },
84
+ style: { background: '#f1f5f9', color: '#64748b', border: '1px dashed #cbd5e1', borderRadius: '8px', padding: '10px' }
85
+ });
86
+ newEdges.push({
87
+ id: 'e-project-empty',
88
+ source: 'project',
89
+ target: 'no-pipelines',
90
+ animated: true,
91
+ style: { stroke: '#cbd5e1', strokeDasharray: '5,5' }
92
+ });
93
+ }
94
+
95
+ // Pipeline Nodes
96
+ pipelines.forEach((pipeline, index) => {
97
+ const pipelineId = `pipeline-${pipeline.name}`; // Using name as ID for uniqueness in graph
98
+ newNodes.push({
99
+ id: pipelineId,
100
+ data: { label: pipeline.name, original: pipeline },
101
+ position: { x: 250, y: index * 120 }, // Increased spacing
102
+ ...nodeDefaults,
103
+ style: { background: '#fff', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '10px', width: 200, cursor: 'pointer' }
104
+ });
105
+ newEdges.push({
106
+ id: `e-project-${pipelineId}`,
107
+ source: 'project',
108
+ target: pipelineId,
109
+ animated: true,
110
+ style: { stroke: '#94a3b8' }
111
+ });
112
+ });
113
+
114
+ // Run Nodes (limit to recent 5 per pipeline to avoid clutter)
115
+ const recentRuns = runs.slice(0, 10);
116
+ recentRuns.forEach((run, index) => {
117
+ const runId = `run-${run.run_id}`;
118
+ const pipelineId = `pipeline-${run.pipeline_name}`;
119
+
120
+ // Only add if pipeline exists in graph
121
+ if (newNodes.find(n => n.id === pipelineId)) {
122
+ newNodes.push({
123
+ id: runId,
124
+ data: { label: `Run: ${run.name || run.run_id.slice(0, 8)}`, original: run },
125
+ position: { x: 600, y: index * 100 },
126
+ ...nodeDefaults,
127
+ style: {
128
+ background: run.status === 'completed' ? '#ecfdf5' : '#fff',
129
+ border: `1px solid ${run.status === 'completed' ? '#10b981' : '#e2e8f0'}`,
130
+ borderRadius: '8px',
131
+ padding: '10px',
132
+ width: 220,
133
+ cursor: 'pointer'
134
+ }
135
+ });
136
+ newEdges.push({
137
+ id: `e-${pipelineId}-${runId}`,
138
+ source: pipelineId,
139
+ target: runId,
140
+ animated: true,
141
+ style: { stroke: '#94a3b8' }
142
+ });
143
+ }
144
+ });
145
+
146
+ setNodes(newNodes);
147
+ setEdges(newEdges);
148
+ } catch (error) {
149
+ console.error('Failed to fetch relations data:', error);
150
+ } finally {
151
+ setLoading(false);
152
+ }
153
+ };
154
+
155
+ fetchData();
156
+ }, [projectId]);
157
+
158
+ const handleNodeClick = (event, node) => {
159
+ if (node.id.startsWith('pipeline-') && node.data.original) {
160
+ // Navigate to pipeline details (assuming route exists, or just log for now)
161
+ // navigate(`/pipelines/${node.data.original.id}`); // We don't have pipeline ID in list usually, just name?
162
+ // Let's assume we can navigate to a pipeline page.
163
+ console.log("Clicked pipeline:", node.data.original);
164
+ } else if (node.id.startsWith('run-') && node.data.original) {
165
+ navigate(`/runs/${node.data.original.run_id}`);
166
+ }
167
+ };
168
+
169
+ if (loading) {
170
+ return <div className="h-[400px] w-full bg-slate-50 dark:bg-slate-800/50 rounded-xl animate-pulse" />;
171
+ }
172
+
173
+ return (
174
+ <div className="h-[500px] w-full bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
175
+ <ReactFlow
176
+ nodes={nodes}
177
+ edges={edges}
178
+ onNodesChange={onNodesChange}
179
+ onEdgesChange={onEdgesChange}
180
+ onNodeClick={handleNodeClick}
181
+ fitView
182
+ attributionPosition="bottom-right"
183
+ >
184
+ <Background color="#94a3b8" gap={16} size={1} />
185
+ <Controls />
186
+ </ReactFlow>
187
+ </div>
188
+ );
189
+ }