flowyml 1.1.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 +207 -0
- flowyml/assets/__init__.py +22 -0
- flowyml/assets/artifact.py +40 -0
- flowyml/assets/base.py +209 -0
- flowyml/assets/dataset.py +100 -0
- flowyml/assets/featureset.py +301 -0
- flowyml/assets/metrics.py +104 -0
- flowyml/assets/model.py +82 -0
- flowyml/assets/registry.py +157 -0
- flowyml/assets/report.py +315 -0
- flowyml/cli/__init__.py +5 -0
- flowyml/cli/experiment.py +232 -0
- flowyml/cli/init.py +256 -0
- flowyml/cli/main.py +327 -0
- flowyml/cli/run.py +75 -0
- flowyml/cli/stack_cli.py +532 -0
- flowyml/cli/ui.py +33 -0
- flowyml/core/__init__.py +68 -0
- flowyml/core/advanced_cache.py +274 -0
- flowyml/core/approval.py +64 -0
- flowyml/core/cache.py +203 -0
- flowyml/core/checkpoint.py +148 -0
- flowyml/core/conditional.py +373 -0
- flowyml/core/context.py +155 -0
- flowyml/core/error_handling.py +419 -0
- flowyml/core/executor.py +354 -0
- flowyml/core/graph.py +185 -0
- flowyml/core/parallel.py +452 -0
- flowyml/core/pipeline.py +764 -0
- flowyml/core/project.py +253 -0
- flowyml/core/resources.py +424 -0
- flowyml/core/scheduler.py +630 -0
- flowyml/core/scheduler_config.py +32 -0
- flowyml/core/step.py +201 -0
- flowyml/core/step_grouping.py +292 -0
- flowyml/core/templates.py +226 -0
- flowyml/core/versioning.py +217 -0
- flowyml/integrations/__init__.py +1 -0
- flowyml/integrations/keras.py +134 -0
- flowyml/monitoring/__init__.py +1 -0
- flowyml/monitoring/alerts.py +57 -0
- flowyml/monitoring/data.py +102 -0
- flowyml/monitoring/llm.py +160 -0
- flowyml/monitoring/monitor.py +57 -0
- flowyml/monitoring/notifications.py +246 -0
- flowyml/registry/__init__.py +5 -0
- flowyml/registry/model_registry.py +491 -0
- flowyml/registry/pipeline_registry.py +55 -0
- flowyml/stacks/__init__.py +27 -0
- flowyml/stacks/base.py +77 -0
- flowyml/stacks/bridge.py +288 -0
- flowyml/stacks/components.py +155 -0
- flowyml/stacks/gcp.py +499 -0
- flowyml/stacks/local.py +112 -0
- flowyml/stacks/migration.py +97 -0
- flowyml/stacks/plugin_config.py +78 -0
- flowyml/stacks/plugins.py +401 -0
- flowyml/stacks/registry.py +226 -0
- flowyml/storage/__init__.py +26 -0
- flowyml/storage/artifacts.py +246 -0
- flowyml/storage/materializers/__init__.py +20 -0
- flowyml/storage/materializers/base.py +133 -0
- flowyml/storage/materializers/keras.py +185 -0
- flowyml/storage/materializers/numpy.py +94 -0
- flowyml/storage/materializers/pandas.py +142 -0
- flowyml/storage/materializers/pytorch.py +135 -0
- flowyml/storage/materializers/sklearn.py +110 -0
- flowyml/storage/materializers/tensorflow.py +152 -0
- flowyml/storage/metadata.py +931 -0
- flowyml/tracking/__init__.py +1 -0
- flowyml/tracking/experiment.py +211 -0
- flowyml/tracking/leaderboard.py +191 -0
- flowyml/tracking/runs.py +145 -0
- flowyml/ui/__init__.py +15 -0
- flowyml/ui/backend/Dockerfile +31 -0
- flowyml/ui/backend/__init__.py +0 -0
- flowyml/ui/backend/auth.py +163 -0
- flowyml/ui/backend/main.py +187 -0
- flowyml/ui/backend/routers/__init__.py +0 -0
- flowyml/ui/backend/routers/assets.py +45 -0
- flowyml/ui/backend/routers/execution.py +179 -0
- flowyml/ui/backend/routers/experiments.py +49 -0
- flowyml/ui/backend/routers/leaderboard.py +118 -0
- flowyml/ui/backend/routers/notifications.py +72 -0
- flowyml/ui/backend/routers/pipelines.py +110 -0
- flowyml/ui/backend/routers/plugins.py +192 -0
- flowyml/ui/backend/routers/projects.py +85 -0
- flowyml/ui/backend/routers/runs.py +66 -0
- flowyml/ui/backend/routers/schedules.py +222 -0
- flowyml/ui/backend/routers/traces.py +84 -0
- flowyml/ui/frontend/Dockerfile +20 -0
- flowyml/ui/frontend/README.md +315 -0
- flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +448 -0
- flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +1 -0
- flowyml/ui/frontend/dist/index.html +16 -0
- flowyml/ui/frontend/index.html +15 -0
- flowyml/ui/frontend/nginx.conf +26 -0
- flowyml/ui/frontend/package-lock.json +3545 -0
- flowyml/ui/frontend/package.json +33 -0
- flowyml/ui/frontend/postcss.config.js +6 -0
- flowyml/ui/frontend/src/App.jsx +21 -0
- flowyml/ui/frontend/src/app/assets/page.jsx +397 -0
- flowyml/ui/frontend/src/app/dashboard/page.jsx +295 -0
- flowyml/ui/frontend/src/app/experiments/[experimentId]/page.jsx +255 -0
- flowyml/ui/frontend/src/app/experiments/page.jsx +360 -0
- flowyml/ui/frontend/src/app/leaderboard/page.jsx +133 -0
- flowyml/ui/frontend/src/app/pipelines/page.jsx +454 -0
- flowyml/ui/frontend/src/app/plugins/page.jsx +48 -0
- flowyml/ui/frontend/src/app/projects/page.jsx +292 -0
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +682 -0
- flowyml/ui/frontend/src/app/runs/page.jsx +470 -0
- flowyml/ui/frontend/src/app/schedules/page.jsx +585 -0
- flowyml/ui/frontend/src/app/settings/page.jsx +314 -0
- flowyml/ui/frontend/src/app/tokens/page.jsx +456 -0
- flowyml/ui/frontend/src/app/traces/page.jsx +246 -0
- flowyml/ui/frontend/src/components/Layout.jsx +108 -0
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +295 -0
- flowyml/ui/frontend/src/components/header/Header.jsx +72 -0
- flowyml/ui/frontend/src/components/plugins/AddPluginDialog.jsx +121 -0
- flowyml/ui/frontend/src/components/plugins/InstalledPlugins.jsx +124 -0
- flowyml/ui/frontend/src/components/plugins/PluginBrowser.jsx +167 -0
- flowyml/ui/frontend/src/components/plugins/PluginManager.jsx +60 -0
- flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +145 -0
- flowyml/ui/frontend/src/components/ui/Badge.jsx +26 -0
- flowyml/ui/frontend/src/components/ui/Button.jsx +34 -0
- flowyml/ui/frontend/src/components/ui/Card.jsx +44 -0
- flowyml/ui/frontend/src/components/ui/CodeSnippet.jsx +38 -0
- flowyml/ui/frontend/src/components/ui/CollapsibleCard.jsx +53 -0
- flowyml/ui/frontend/src/components/ui/DataView.jsx +175 -0
- flowyml/ui/frontend/src/components/ui/EmptyState.jsx +49 -0
- flowyml/ui/frontend/src/components/ui/ExecutionStatus.jsx +122 -0
- flowyml/ui/frontend/src/components/ui/KeyValue.jsx +25 -0
- flowyml/ui/frontend/src/components/ui/ProjectSelector.jsx +134 -0
- flowyml/ui/frontend/src/contexts/ProjectContext.jsx +79 -0
- flowyml/ui/frontend/src/contexts/ThemeContext.jsx +54 -0
- flowyml/ui/frontend/src/index.css +11 -0
- flowyml/ui/frontend/src/layouts/MainLayout.jsx +23 -0
- flowyml/ui/frontend/src/main.jsx +10 -0
- flowyml/ui/frontend/src/router/index.jsx +39 -0
- flowyml/ui/frontend/src/services/pluginService.js +90 -0
- flowyml/ui/frontend/src/utils/api.js +47 -0
- flowyml/ui/frontend/src/utils/cn.js +6 -0
- flowyml/ui/frontend/tailwind.config.js +31 -0
- flowyml/ui/frontend/vite.config.js +21 -0
- flowyml/ui/utils.py +77 -0
- flowyml/utils/__init__.py +67 -0
- flowyml/utils/config.py +308 -0
- flowyml/utils/debug.py +240 -0
- flowyml/utils/environment.py +346 -0
- flowyml/utils/git.py +319 -0
- flowyml/utils/logging.py +61 -0
- flowyml/utils/performance.py +314 -0
- flowyml/utils/stack_config.py +296 -0
- flowyml/utils/validation.py +270 -0
- flowyml-1.1.0.dist-info/METADATA +372 -0
- flowyml-1.1.0.dist-info/RECORD +159 -0
- flowyml-1.1.0.dist-info/WHEEL +4 -0
- flowyml-1.1.0.dist-info/entry_points.txt +3 -0
- flowyml-1.1.0.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import React, { useEffect, useState, useMemo } from 'react';
|
|
2
|
+
import { fetchApi } from '../../../utils/api';
|
|
3
|
+
import { useParams, Link } from 'react-router-dom';
|
|
4
|
+
import { CheckCircle, XCircle, Clock, Calendar, Package, ArrowRight, BarChart2, FileText, Database, Box, ChevronRight, Activity, Layers, Code2, Terminal, Info, X, Maximize2, TrendingUp, Download, ArrowDownCircle, ArrowUpCircle, Tag, Zap, AlertCircle, FolderPlus } from 'lucide-react';
|
|
5
|
+
import { Card } from '../../../components/ui/Card';
|
|
6
|
+
import { Badge } from '../../../components/ui/Badge';
|
|
7
|
+
import { Button } from '../../../components/ui/Button';
|
|
8
|
+
import { format } from 'date-fns';
|
|
9
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
10
|
+
import { PipelineGraph } from '../../../components/PipelineGraph';
|
|
11
|
+
import { CodeSnippet } from '../../../components/ui/CodeSnippet';
|
|
12
|
+
|
|
13
|
+
export function RunDetails() {
|
|
14
|
+
const { runId } = useParams();
|
|
15
|
+
const [run, setRun] = useState(null);
|
|
16
|
+
const [artifacts, setArtifacts] = useState([]);
|
|
17
|
+
const [metrics, setMetrics] = useState([]);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [selectedStep, setSelectedStep] = useState(null);
|
|
20
|
+
const [activeTab, setActiveTab] = useState('overview');
|
|
21
|
+
const [selectedArtifact, setSelectedArtifact] = useState(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const fetchData = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const [runRes, assetsRes] = await Promise.all([
|
|
27
|
+
fetchApi(`/api/runs/${runId}`),
|
|
28
|
+
fetchApi(`/api/assets?run_id=${runId}`)
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const runData = await runRes.json();
|
|
32
|
+
const assetsData = await assetsRes.json();
|
|
33
|
+
|
|
34
|
+
let metricsData = [];
|
|
35
|
+
try {
|
|
36
|
+
const mRes = await fetchApi(`/api/runs/${runId}/metrics`);
|
|
37
|
+
if (mRes.ok) {
|
|
38
|
+
const mJson = await mRes.json();
|
|
39
|
+
metricsData = mJson.metrics || [];
|
|
40
|
+
}
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.warn("Failed to fetch metrics", e);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setRun(runData);
|
|
46
|
+
setArtifacts(assetsData.assets || []);
|
|
47
|
+
setMetrics(metricsData);
|
|
48
|
+
|
|
49
|
+
// Auto-select first step
|
|
50
|
+
if (runData.steps && Object.keys(runData.steps).length > 0) {
|
|
51
|
+
setSelectedStep(Object.keys(runData.steps)[0]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setLoading(false);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(err);
|
|
57
|
+
setLoading(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
fetchData();
|
|
62
|
+
}, [runId]);
|
|
63
|
+
|
|
64
|
+
if (loading) return (
|
|
65
|
+
<div className="flex items-center justify-center h-96">
|
|
66
|
+
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!run) return <div className="p-8 text-center text-slate-500">Run not found</div>;
|
|
71
|
+
|
|
72
|
+
const statusVariant =
|
|
73
|
+
run.status === 'completed' ? 'success' :
|
|
74
|
+
run.status === 'failed' ? 'danger' : 'warning';
|
|
75
|
+
|
|
76
|
+
const selectedStepData = selectedStep ? run.steps?.[selectedStep] : null;
|
|
77
|
+
const selectedStepArtifacts = artifacts.filter(a => a.step === selectedStep);
|
|
78
|
+
const selectedStepMetrics = metrics.filter(m => m.step === selectedStep || m.name.startsWith(selectedStep));
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="space-y-6">
|
|
82
|
+
{/* Header */}
|
|
83
|
+
<div className="flex items-center justify-between bg-white p-6 rounded-xl border border-slate-100 shadow-sm">
|
|
84
|
+
<div>
|
|
85
|
+
<div className="flex items-center gap-2 mb-2">
|
|
86
|
+
<Link to="/runs" className="text-sm text-slate-500 hover:text-slate-700 transition-colors">Runs</Link>
|
|
87
|
+
<ChevronRight size={14} className="text-slate-300" />
|
|
88
|
+
<span className="text-sm text-slate-900 font-medium">{run.run_id}</span>
|
|
89
|
+
</div>
|
|
90
|
+
<h2 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
|
91
|
+
<div className={`w-3 h-3 rounded-full ${run.status === 'completed' ? 'bg-emerald-500' : run.status === 'failed' ? 'bg-rose-500' : 'bg-amber-500'}`} />
|
|
92
|
+
Run: <span className="font-mono text-slate-500">{run.run_id.substring(0, 8)}</span>
|
|
93
|
+
</h2>
|
|
94
|
+
<p className="text-slate-500 mt-1 flex items-center gap-2">
|
|
95
|
+
<Layers size={16} />
|
|
96
|
+
Pipeline: <span className="font-medium text-slate-700">{run.pipeline_name}</span>
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="flex flex-col items-end gap-2">
|
|
100
|
+
<div className="flex items-center gap-2">
|
|
101
|
+
<SimpleProjectSelector runId={run.run_id} currentProject={run.project} />
|
|
102
|
+
<Badge variant={statusVariant} className="text-sm px-4 py-1.5 uppercase tracking-wide shadow-sm">
|
|
103
|
+
{run.status}
|
|
104
|
+
</Badge>
|
|
105
|
+
</div>
|
|
106
|
+
<span className="text-xs text-slate-400 font-mono">ID: {run.run_id}</span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Stats Grid */}
|
|
111
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
112
|
+
<StatsCard
|
|
113
|
+
icon={<Clock size={24} />}
|
|
114
|
+
label="Duration"
|
|
115
|
+
value={run.duration ? `${run.duration.toFixed(2)}s` : '-'}
|
|
116
|
+
color="blue"
|
|
117
|
+
/>
|
|
118
|
+
<StatsCard
|
|
119
|
+
icon={<Calendar size={24} />}
|
|
120
|
+
label="Started At"
|
|
121
|
+
value={run.start_time ? format(new Date(run.start_time), 'MMM d, HH:mm:ss') : '-'}
|
|
122
|
+
color="purple"
|
|
123
|
+
/>
|
|
124
|
+
<StatsCard
|
|
125
|
+
icon={<CheckCircle size={24} />}
|
|
126
|
+
label="Steps Completed"
|
|
127
|
+
value={`${run.steps ? Object.values(run.steps).filter(s => s.success).length : 0} / ${run.steps ? Object.keys(run.steps).length : 0}`}
|
|
128
|
+
color="emerald"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Main Content - Split View */}
|
|
133
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
134
|
+
{/* DAG Visualization - 2 columns */}
|
|
135
|
+
<div className="lg:col-span-2">
|
|
136
|
+
<h3 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
|
137
|
+
<Activity className="text-primary-500" /> Pipeline Execution Graph
|
|
138
|
+
</h3>
|
|
139
|
+
<div className="h-[600px]">
|
|
140
|
+
{run.dag ? (
|
|
141
|
+
<PipelineGraph
|
|
142
|
+
dag={run.dag}
|
|
143
|
+
steps={run.steps}
|
|
144
|
+
selectedStep={selectedStep}
|
|
145
|
+
onStepSelect={setSelectedStep}
|
|
146
|
+
/>
|
|
147
|
+
) : (
|
|
148
|
+
<Card className="h-full flex items-center justify-center">
|
|
149
|
+
<p className="text-slate-500">DAG visualization not available</p>
|
|
150
|
+
</Card>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Step Details Panel - 1 column */}
|
|
156
|
+
<div>
|
|
157
|
+
<h3 className="text-xl font-bold text-slate-900 mb-4 flex items-center gap-2">
|
|
158
|
+
<Info className="text-primary-500" /> Step Details
|
|
159
|
+
</h3>
|
|
160
|
+
|
|
161
|
+
{selectedStepData ? (
|
|
162
|
+
<Card className="overflow-hidden">
|
|
163
|
+
{/* Step Header */}
|
|
164
|
+
<div className="pb-4 border-b border-slate-100">
|
|
165
|
+
<h4 className="text-lg font-bold text-slate-900 mb-2">{selectedStep}</h4>
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
167
|
+
<Badge variant={selectedStepData.success ? 'success' : 'danger'} className="text-xs">
|
|
168
|
+
{selectedStepData.success ? 'Success' : 'Failed'}
|
|
169
|
+
</Badge>
|
|
170
|
+
{selectedStepData.cached && (
|
|
171
|
+
<Badge variant="secondary" className="text-xs bg-blue-50 text-blue-700">
|
|
172
|
+
Cached
|
|
173
|
+
</Badge>
|
|
174
|
+
)}
|
|
175
|
+
<span className="text-xs font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded">
|
|
176
|
+
{selectedStepData.duration?.toFixed(2)}s
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Tabs */}
|
|
182
|
+
<div className="flex gap-2 border-b border-slate-100 mt-4">
|
|
183
|
+
<TabButton active={activeTab === 'overview'} onClick={() => setActiveTab('overview')}>
|
|
184
|
+
<Info size={16} /> Overview
|
|
185
|
+
</TabButton>
|
|
186
|
+
<TabButton active={activeTab === 'code'} onClick={() => setActiveTab('code')}>
|
|
187
|
+
<Code2 size={16} /> Code
|
|
188
|
+
</TabButton>
|
|
189
|
+
<TabButton active={activeTab === 'artifacts'} onClick={() => setActiveTab('artifacts')}>
|
|
190
|
+
<Package size={16} /> Artifacts
|
|
191
|
+
</TabButton>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Tab Content */}
|
|
195
|
+
<div className="mt-4 max-h-[450px] overflow-y-auto">
|
|
196
|
+
{activeTab === 'overview' && (
|
|
197
|
+
<OverviewTab stepData={selectedStepData} metrics={selectedStepMetrics} />
|
|
198
|
+
)}
|
|
199
|
+
{activeTab === 'code' && (
|
|
200
|
+
<CodeTab sourceCode={selectedStepData.source_code} />
|
|
201
|
+
)}
|
|
202
|
+
{activeTab === 'artifacts' && (
|
|
203
|
+
<ArtifactsTab
|
|
204
|
+
artifacts={selectedStepArtifacts}
|
|
205
|
+
onArtifactClick={setSelectedArtifact}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</Card>
|
|
210
|
+
) : (
|
|
211
|
+
<Card className="p-12 text-center">
|
|
212
|
+
<p className="text-slate-500">Select a step to view details</p>
|
|
213
|
+
</Card>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{/* Artifact Detail Modal */}
|
|
219
|
+
<ArtifactModal
|
|
220
|
+
artifact={selectedArtifact}
|
|
221
|
+
onClose={() => setSelectedArtifact(null)}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function StatsCard({ icon, label, value, color }) {
|
|
228
|
+
const colorClasses = {
|
|
229
|
+
blue: "bg-blue-50 text-blue-600",
|
|
230
|
+
purple: "bg-purple-50 text-purple-600",
|
|
231
|
+
emerald: "bg-emerald-50 text-emerald-600",
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Card className="hover:shadow-md transition-shadow duration-200">
|
|
236
|
+
<div className="flex items-center gap-4">
|
|
237
|
+
<div className={`p-3 rounded-xl ${colorClasses[color]}`}>
|
|
238
|
+
{icon}
|
|
239
|
+
</div>
|
|
240
|
+
<div>
|
|
241
|
+
<p className="text-sm text-slate-500 font-medium">{label}</p>
|
|
242
|
+
<p className="text-xl font-bold text-slate-900">{value}</p>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</Card>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function TabButton({ active, onClick, children }) {
|
|
250
|
+
return (
|
|
251
|
+
<button
|
|
252
|
+
onClick={onClick}
|
|
253
|
+
className={`
|
|
254
|
+
flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors
|
|
255
|
+
${active
|
|
256
|
+
? 'text-primary-600 border-b-2 border-primary-600'
|
|
257
|
+
: 'text-slate-500 hover:text-slate-700'
|
|
258
|
+
}
|
|
259
|
+
`}
|
|
260
|
+
>
|
|
261
|
+
{children}
|
|
262
|
+
</button>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function OverviewTab({ stepData, metrics }) {
|
|
267
|
+
const formatDuration = (seconds) => {
|
|
268
|
+
if (!seconds) return 'N/A';
|
|
269
|
+
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
|
|
270
|
+
if (seconds < 60) return `${seconds.toFixed(2)}s`;
|
|
271
|
+
const mins = Math.floor(seconds / 60);
|
|
272
|
+
const secs = (seconds % 60).toFixed(0);
|
|
273
|
+
return `${mins}m ${secs}s`;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<div className="space-y-6">
|
|
278
|
+
{/* Status & Execution Info */}
|
|
279
|
+
<div className="grid grid-cols-2 gap-4">
|
|
280
|
+
<div className="p-4 bg-gradient-to-br from-slate-50 to-white rounded-xl border border-slate-200">
|
|
281
|
+
<div className="flex items-center gap-2 mb-2">
|
|
282
|
+
<Clock size={14} className="text-slate-400" />
|
|
283
|
+
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">Duration</span>
|
|
284
|
+
</div>
|
|
285
|
+
<p className="text-2xl font-bold text-slate-900">
|
|
286
|
+
{formatDuration(stepData.duration)}
|
|
287
|
+
</p>
|
|
288
|
+
</div>
|
|
289
|
+
<div className="p-4 bg-gradient-to-br from-slate-50 to-white rounded-xl border border-slate-200">
|
|
290
|
+
<div className="flex items-center gap-2 mb-2">
|
|
291
|
+
<Activity size={14} className="text-slate-400" />
|
|
292
|
+
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">Status</span>
|
|
293
|
+
</div>
|
|
294
|
+
<div className="flex items-center gap-2">
|
|
295
|
+
{stepData.success ? (
|
|
296
|
+
<>
|
|
297
|
+
<CheckCircle size={20} className="text-emerald-500" />
|
|
298
|
+
<span className="text-lg font-bold text-emerald-700">Success</span>
|
|
299
|
+
</>
|
|
300
|
+
) : stepData.error ? (
|
|
301
|
+
<>
|
|
302
|
+
<XCircle size={20} className="text-rose-500" />
|
|
303
|
+
<span className="text-lg font-bold text-rose-700">Failed</span>
|
|
304
|
+
</>
|
|
305
|
+
) : (
|
|
306
|
+
<>
|
|
307
|
+
<Clock size={20} className="text-amber-500" />
|
|
308
|
+
<span className="text-lg font-bold text-amber-700">Pending</span>
|
|
309
|
+
</>
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{/* Inputs & Outputs */}
|
|
316
|
+
{(stepData.inputs?.length > 0 || stepData.outputs?.length > 0) && (
|
|
317
|
+
<div className="space-y-4">
|
|
318
|
+
<h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide flex items-center gap-2">
|
|
319
|
+
<Database size={16} />
|
|
320
|
+
Data Flow
|
|
321
|
+
</h5>
|
|
322
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
323
|
+
{stepData.inputs?.length > 0 && (
|
|
324
|
+
<div className="p-4 bg-blue-50/50 dark:bg-blue-900/10 rounded-xl border border-blue-100 dark:border-blue-800">
|
|
325
|
+
<div className="flex items-center gap-2 mb-3">
|
|
326
|
+
<ArrowDownCircle size={16} className="text-blue-600" />
|
|
327
|
+
<span className="text-sm font-semibold text-blue-900 dark:text-blue-100">Inputs</span>
|
|
328
|
+
<Badge variant="secondary" className="ml-auto text-xs">{stepData.inputs.length}</Badge>
|
|
329
|
+
</div>
|
|
330
|
+
<div className="space-y-1.5">
|
|
331
|
+
{stepData.inputs.map((input, idx) => (
|
|
332
|
+
<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">
|
|
333
|
+
<Database size={12} className="text-blue-500 flex-shrink-0" />
|
|
334
|
+
<span className="text-sm font-mono text-slate-700 dark:text-slate-200 truncate">{input}</span>
|
|
335
|
+
</div>
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
{stepData.outputs?.length > 0 && (
|
|
341
|
+
<div className="p-4 bg-purple-50/50 dark:bg-purple-900/10 rounded-xl border border-purple-100 dark:border-purple-800">
|
|
342
|
+
<div className="flex items-center gap-2 mb-3">
|
|
343
|
+
<ArrowUpCircle size={16} className="text-purple-600" />
|
|
344
|
+
<span className="text-sm font-semibold text-purple-900 dark:text-purple-100">Outputs</span>
|
|
345
|
+
<Badge variant="secondary" className="ml-auto text-xs">{stepData.outputs.length}</Badge>
|
|
346
|
+
</div>
|
|
347
|
+
<div className="space-y-1.5">
|
|
348
|
+
{stepData.outputs.map((output, idx) => (
|
|
349
|
+
<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">
|
|
350
|
+
<Box size={12} className="text-purple-500 flex-shrink-0" />
|
|
351
|
+
<span className="text-sm font-mono text-slate-700 dark:text-slate-200 truncate">{output}</span>
|
|
352
|
+
</div>
|
|
353
|
+
))}
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* Tags/Metadata */}
|
|
362
|
+
{stepData.tags && Object.keys(stepData.tags).length > 0 && (
|
|
363
|
+
<div>
|
|
364
|
+
<h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
365
|
+
<Tag size={16} />
|
|
366
|
+
Metadata
|
|
367
|
+
</h5>
|
|
368
|
+
<div className="grid grid-cols-2 gap-3">
|
|
369
|
+
{Object.entries(stepData.tags).map(([key, value]) => (
|
|
370
|
+
<div key={key} className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
|
|
371
|
+
<div className="text-xs text-slate-500 dark:text-slate-400 mb-1">{key}</div>
|
|
372
|
+
<div className="text-sm font-mono font-medium text-slate-900 dark:text-white truncate">{String(value)}</div>
|
|
373
|
+
</div>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
|
|
379
|
+
{/* Cached Badge */}
|
|
380
|
+
{stepData.cached && (
|
|
381
|
+
<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">
|
|
382
|
+
<div className="flex items-center gap-3">
|
|
383
|
+
<Zap size={24} className="text-blue-600" />
|
|
384
|
+
<div>
|
|
385
|
+
<h6 className="font-bold text-blue-900 dark:text-blue-100">Cached Result</h6>
|
|
386
|
+
<p className="text-sm text-blue-700 dark:text-blue-300">This step used cached results from a previous run</p>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
391
|
+
|
|
392
|
+
{/* Error */}
|
|
393
|
+
{stepData.error && (
|
|
394
|
+
<div>
|
|
395
|
+
<h5 className="text-sm font-bold text-rose-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
396
|
+
<AlertCircle size={16} />
|
|
397
|
+
Error Details
|
|
398
|
+
</h5>
|
|
399
|
+
<div className="p-4 bg-rose-50 dark:bg-rose-900/20 rounded-xl border-2 border-rose-200 dark:border-rose-800">
|
|
400
|
+
<pre className="text-sm font-mono text-rose-700 dark:text-rose-300 whitespace-pre-wrap overflow-x-auto">
|
|
401
|
+
{stepData.error}
|
|
402
|
+
</pre>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
{/* Metrics with Visualization */}
|
|
408
|
+
{metrics?.length > 0 && (
|
|
409
|
+
<div>
|
|
410
|
+
<h5 className="text-sm font-bold text-slate-700 uppercase tracking-wide mb-3 flex items-center gap-2">
|
|
411
|
+
<TrendingUp size={16} />
|
|
412
|
+
Performance Metrics
|
|
413
|
+
</h5>
|
|
414
|
+
<div className="grid grid-cols-2 gap-3">
|
|
415
|
+
{metrics.map((metric, idx) => (
|
|
416
|
+
<MetricCard key={idx} metric={metric} />
|
|
417
|
+
))}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function MetricCard({ metric }) {
|
|
426
|
+
const isNumeric = typeof metric.value === 'number';
|
|
427
|
+
const displayValue = isNumeric ? metric.value.toFixed(4) : metric.value;
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<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">
|
|
431
|
+
<span className="text-xs text-slate-500 block truncate mb-1" title={metric.name}>
|
|
432
|
+
{metric.name}
|
|
433
|
+
</span>
|
|
434
|
+
<div className="flex items-baseline gap-2">
|
|
435
|
+
<span className="text-xl font-mono font-bold text-slate-900 group-hover:text-primary-600 transition-colors">
|
|
436
|
+
{displayValue}
|
|
437
|
+
</span>
|
|
438
|
+
{isNumeric && metric.value > 0 && (
|
|
439
|
+
<TrendingUp size={14} className="text-emerald-500" />
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function CodeTab({ sourceCode }) {
|
|
447
|
+
return (
|
|
448
|
+
<CodeSnippet
|
|
449
|
+
code={sourceCode || '# Source code not available'}
|
|
450
|
+
language="python"
|
|
451
|
+
title="Step Source Code"
|
|
452
|
+
/>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function ArtifactsTab({ artifacts, onArtifactClick }) {
|
|
457
|
+
return (
|
|
458
|
+
<div>
|
|
459
|
+
<h5 className="text-sm font-semibold text-slate-700 mb-3">Produced Artifacts</h5>
|
|
460
|
+
{artifacts?.length > 0 ? (
|
|
461
|
+
<div className="space-y-2">
|
|
462
|
+
{artifacts.map(art => (
|
|
463
|
+
<motion.div
|
|
464
|
+
key={art.artifact_id}
|
|
465
|
+
whileHover={{ scale: 1.02 }}
|
|
466
|
+
whileTap={{ scale: 0.98 }}
|
|
467
|
+
onClick={() => onArtifactClick(art)}
|
|
468
|
+
className="group flex items-center gap-3 p-3 bg-slate-50 hover:bg-white rounded-lg border border-slate-100 hover:border-primary-300 hover:shadow-md transition-all cursor-pointer"
|
|
469
|
+
>
|
|
470
|
+
<div className="p-2 bg-white rounded-md text-slate-500 shadow-sm group-hover:text-primary-600 group-hover:scale-110 transition-all">
|
|
471
|
+
{art.type === 'Dataset' ? <Database size={18} /> :
|
|
472
|
+
art.type === 'Model' ? <Box size={18} /> :
|
|
473
|
+
art.type === 'Metrics' ? <BarChart2 size={18} /> :
|
|
474
|
+
<FileText size={18} />}
|
|
475
|
+
</div>
|
|
476
|
+
<div className="min-w-0 flex-1">
|
|
477
|
+
<p className="text-sm font-semibold text-slate-900 truncate group-hover:text-primary-600 transition-colors">
|
|
478
|
+
{art.name}
|
|
479
|
+
</p>
|
|
480
|
+
<p className="text-xs text-slate-500 truncate">{art.type}</p>
|
|
481
|
+
</div>
|
|
482
|
+
<div className="flex items-center gap-2">
|
|
483
|
+
<Maximize2 size={14} className="text-slate-300 group-hover:text-primary-400 transition-colors" />
|
|
484
|
+
<ArrowRight size={14} className="text-slate-300 group-hover:text-primary-400 opacity-0 group-hover:opacity-100 transition-all" />
|
|
485
|
+
</div>
|
|
486
|
+
</motion.div>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
) : (
|
|
490
|
+
<p className="text-sm text-slate-500 italic">No artifacts produced by this step</p>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function ArtifactModal({ artifact, onClose }) {
|
|
497
|
+
if (!artifact) return null;
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<AnimatePresence>
|
|
501
|
+
<motion.div
|
|
502
|
+
initial={{ opacity: 0 }}
|
|
503
|
+
animate={{ opacity: 1 }}
|
|
504
|
+
exit={{ opacity: 0 }}
|
|
505
|
+
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
|
506
|
+
onClick={onClose}
|
|
507
|
+
>
|
|
508
|
+
<motion.div
|
|
509
|
+
initial={{ scale: 0.9, opacity: 0 }}
|
|
510
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
511
|
+
exit={{ scale: 0.9, opacity: 0 }}
|
|
512
|
+
onClick={(e) => e.stopPropagation()}
|
|
513
|
+
className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[80vh] overflow-hidden"
|
|
514
|
+
>
|
|
515
|
+
{/* Header */}
|
|
516
|
+
<div className="flex items-center justify-between p-6 border-b border-slate-100 bg-gradient-to-r from-primary-50 to-purple-50">
|
|
517
|
+
<div className="flex items-center gap-3">
|
|
518
|
+
<div className="p-3 bg-white rounded-xl shadow-sm">
|
|
519
|
+
{artifact.type === 'Dataset' ? <Database size={24} className="text-blue-600" /> :
|
|
520
|
+
artifact.type === 'Model' ? <Box size={24} className="text-purple-600" /> :
|
|
521
|
+
artifact.type === 'Metrics' ? <BarChart2 size={24} className="text-emerald-600" /> :
|
|
522
|
+
<FileText size={24} className="text-slate-600" />}
|
|
523
|
+
</div>
|
|
524
|
+
<div>
|
|
525
|
+
<h3 className="text-xl font-bold text-slate-900">{artifact.name}</h3>
|
|
526
|
+
<p className="text-sm text-slate-500">{artifact.type}</p>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
<button
|
|
530
|
+
onClick={onClose}
|
|
531
|
+
className="p-2 hover:bg-white rounded-lg transition-colors"
|
|
532
|
+
>
|
|
533
|
+
<X size={20} className="text-slate-400" />
|
|
534
|
+
</button>
|
|
535
|
+
</div>
|
|
536
|
+
|
|
537
|
+
{/* Content */}
|
|
538
|
+
<div className="p-6 overflow-y-auto max-h-[60vh]">
|
|
539
|
+
<div className="space-y-4">
|
|
540
|
+
{/* Properties */}
|
|
541
|
+
{artifact.properties && Object.keys(artifact.properties).length > 0 && (
|
|
542
|
+
<div>
|
|
543
|
+
<h4 className="text-sm font-semibold text-slate-700 mb-3">Properties</h4>
|
|
544
|
+
<div className="grid grid-cols-2 gap-3">
|
|
545
|
+
{Object.entries(artifact.properties).map(([key, value]) => (
|
|
546
|
+
<div key={key} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
|
547
|
+
<span className="text-xs text-slate-500 block mb-1">{key}</span>
|
|
548
|
+
<span className="text-sm font-mono font-semibold text-slate-900">
|
|
549
|
+
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
|
550
|
+
</span>
|
|
551
|
+
</div>
|
|
552
|
+
))}
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
|
|
557
|
+
{/* Value Preview */}
|
|
558
|
+
{artifact.value && (
|
|
559
|
+
<div>
|
|
560
|
+
<h4 className="text-sm font-semibold text-slate-700 mb-3">Value Preview</h4>
|
|
561
|
+
<pre className="p-4 bg-slate-900 text-slate-100 rounded-lg text-xs font-mono overflow-x-auto">
|
|
562
|
+
{artifact.value}
|
|
563
|
+
</pre>
|
|
564
|
+
</div>
|
|
565
|
+
)}
|
|
566
|
+
|
|
567
|
+
{/* Metadata */}
|
|
568
|
+
<div>
|
|
569
|
+
<h4 className="text-sm font-semibold text-slate-700 mb-3">Metadata</h4>
|
|
570
|
+
<div className="space-y-2 text-sm">
|
|
571
|
+
<div className="flex justify-between">
|
|
572
|
+
<span className="text-slate-500">Artifact ID:</span>
|
|
573
|
+
<span className="font-mono text-xs text-slate-700">{artifact.artifact_id}</span>
|
|
574
|
+
</div>
|
|
575
|
+
<div className="flex justify-between">
|
|
576
|
+
<span className="text-slate-500">Step:</span>
|
|
577
|
+
<span className="font-medium text-slate-700">{artifact.step}</span>
|
|
578
|
+
</div>
|
|
579
|
+
{artifact.created_at && (
|
|
580
|
+
<div className="flex justify-between">
|
|
581
|
+
<span className="text-slate-500">Created:</span>
|
|
582
|
+
<span className="text-slate-700">{format(new Date(artifact.created_at), 'MMM d, yyyy HH:mm:ss')}</span>
|
|
583
|
+
</div>
|
|
584
|
+
)}
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
|
|
590
|
+
{/* Footer */}
|
|
591
|
+
<div className="p-4 border-t border-slate-100 bg-slate-50 flex justify-end gap-2">
|
|
592
|
+
<Button variant="ghost" onClick={onClose}>Close</Button>
|
|
593
|
+
<Button variant="primary">Download</Button>
|
|
594
|
+
</div>
|
|
595
|
+
</motion.div>
|
|
596
|
+
</motion.div>
|
|
597
|
+
</AnimatePresence>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function SimpleProjectSelector({ runId, currentProject }) {
|
|
602
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
603
|
+
const [projects, setProjects] = useState([]);
|
|
604
|
+
const [updating, setUpdating] = useState(false);
|
|
605
|
+
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
if (isOpen) {
|
|
608
|
+
fetch('/api/projects/')
|
|
609
|
+
.then(res => res.json())
|
|
610
|
+
.then(data => setProjects(data))
|
|
611
|
+
.catch(err => console.error('Failed to load projects:', err));
|
|
612
|
+
}
|
|
613
|
+
}, [isOpen]);
|
|
614
|
+
|
|
615
|
+
const handleSelectProject = async (projectName) => {
|
|
616
|
+
setUpdating(true);
|
|
617
|
+
try {
|
|
618
|
+
await fetch(`/api/runs/${runId}/project`, {
|
|
619
|
+
method: 'PUT',
|
|
620
|
+
headers: { 'Content-Type': 'application/json' },
|
|
621
|
+
body: JSON.stringify({ project_name: projectName })
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const toast = document.createElement('div');
|
|
625
|
+
toast.className = 'fixed top-4 right-4 px-4 py-3 rounded-lg shadow-lg z-50 bg-green-500 text-white';
|
|
626
|
+
toast.textContent = `Run added to project ${projectName}`;
|
|
627
|
+
document.body.appendChild(toast);
|
|
628
|
+
setTimeout(() => document.body.removeChild(toast), 3000);
|
|
629
|
+
|
|
630
|
+
setIsOpen(false);
|
|
631
|
+
setTimeout(() => window.location.reload(), 500);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
console.error('Failed to update project:', error);
|
|
634
|
+
} finally {
|
|
635
|
+
setUpdating(false);
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
<div className="relative">
|
|
641
|
+
<button
|
|
642
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
643
|
+
disabled={updating}
|
|
644
|
+
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"
|
|
645
|
+
title={currentProject ? `Current project: ${currentProject}` : 'Add to project'}
|
|
646
|
+
>
|
|
647
|
+
<FolderPlus size={16} />
|
|
648
|
+
{currentProject || 'Add to Project'}
|
|
649
|
+
</button>
|
|
650
|
+
|
|
651
|
+
{isOpen && (
|
|
652
|
+
<>
|
|
653
|
+
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
|
|
654
|
+
<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">
|
|
655
|
+
<div className="p-2 border-b border-slate-100 dark:border-slate-700">
|
|
656
|
+
<span className="text-xs font-semibold text-slate-500 px-2">Select Project</span>
|
|
657
|
+
</div>
|
|
658
|
+
<div className="max-h-64 overflow-y-auto p-1">
|
|
659
|
+
{projects.length > 0 ? (
|
|
660
|
+
projects.map(p => (
|
|
661
|
+
<button
|
|
662
|
+
key={p.name}
|
|
663
|
+
onClick={() => handleSelectProject(p.name)}
|
|
664
|
+
disabled={updating}
|
|
665
|
+
className={`w-full text-left px-3 py-2 text-sm rounded-lg transition-colors ${p.name === currentProject
|
|
666
|
+
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 font-medium'
|
|
667
|
+
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700'
|
|
668
|
+
}`}
|
|
669
|
+
>
|
|
670
|
+
{p.name} {p.name === currentProject && '✓'}
|
|
671
|
+
</button>
|
|
672
|
+
))
|
|
673
|
+
) : (
|
|
674
|
+
<div className="px-3 py-2 text-sm text-slate-400">No projects found</div>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
</>
|
|
679
|
+
)}
|
|
680
|
+
</div>
|
|
681
|
+
);
|
|
682
|
+
}
|