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.
- flowyml/__init__.py +3 -0
- flowyml/assets/base.py +10 -0
- flowyml/assets/metrics.py +6 -0
- flowyml/cli/main.py +108 -2
- flowyml/cli/run.py +9 -2
- flowyml/core/execution_status.py +52 -0
- flowyml/core/hooks.py +106 -0
- flowyml/core/observability.py +210 -0
- flowyml/core/orchestrator.py +274 -0
- flowyml/core/pipeline.py +193 -231
- flowyml/core/project.py +34 -2
- flowyml/core/remote_orchestrator.py +109 -0
- flowyml/core/resources.py +34 -17
- flowyml/core/retry_policy.py +80 -0
- flowyml/core/scheduler.py +9 -9
- flowyml/core/scheduler_config.py +2 -3
- flowyml/core/step.py +18 -1
- flowyml/core/submission_result.py +53 -0
- flowyml/integrations/keras.py +95 -22
- flowyml/monitoring/alerts.py +2 -2
- flowyml/stacks/__init__.py +15 -0
- flowyml/stacks/aws.py +599 -0
- flowyml/stacks/azure.py +295 -0
- flowyml/stacks/bridge.py +9 -9
- flowyml/stacks/components.py +24 -2
- flowyml/stacks/gcp.py +158 -11
- flowyml/stacks/local.py +5 -0
- flowyml/stacks/plugins.py +2 -2
- flowyml/stacks/registry.py +21 -0
- flowyml/storage/artifacts.py +15 -5
- flowyml/storage/materializers/__init__.py +2 -0
- flowyml/storage/materializers/base.py +33 -0
- flowyml/storage/materializers/cloudpickle.py +74 -0
- flowyml/storage/metadata.py +3 -881
- flowyml/storage/remote.py +590 -0
- flowyml/storage/sql.py +911 -0
- flowyml/ui/backend/dependencies.py +28 -0
- flowyml/ui/backend/main.py +43 -80
- flowyml/ui/backend/routers/assets.py +483 -17
- flowyml/ui/backend/routers/client.py +46 -0
- flowyml/ui/backend/routers/execution.py +13 -2
- flowyml/ui/backend/routers/experiments.py +97 -14
- flowyml/ui/backend/routers/metrics.py +168 -0
- flowyml/ui/backend/routers/pipelines.py +77 -12
- flowyml/ui/backend/routers/projects.py +33 -7
- flowyml/ui/backend/routers/runs.py +221 -12
- flowyml/ui/backend/routers/schedules.py +5 -21
- flowyml/ui/backend/routers/stats.py +14 -0
- flowyml/ui/backend/routers/traces.py +37 -53
- flowyml/ui/frontend/dist/assets/index-DcYwrn2j.css +1 -0
- flowyml/ui/frontend/dist/assets/index-Dlz_ygOL.js +592 -0
- flowyml/ui/frontend/dist/index.html +2 -2
- flowyml/ui/frontend/src/App.jsx +4 -1
- flowyml/ui/frontend/src/app/assets/page.jsx +260 -230
- flowyml/ui/frontend/src/app/dashboard/page.jsx +38 -7
- flowyml/ui/frontend/src/app/experiments/page.jsx +61 -314
- flowyml/ui/frontend/src/app/observability/page.jsx +277 -0
- flowyml/ui/frontend/src/app/pipelines/page.jsx +79 -402
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectArtifactsList.jsx +151 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectExperimentsList.jsx +145 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHeader.jsx +45 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectHierarchy.jsx +467 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectMetricsPanel.jsx +253 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectPipelinesList.jsx +105 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRelations.jsx +189 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectRunsList.jsx +136 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/_components/ProjectTabs.jsx +95 -0
- flowyml/ui/frontend/src/app/projects/[projectId]/page.jsx +326 -0
- flowyml/ui/frontend/src/app/projects/page.jsx +13 -3
- flowyml/ui/frontend/src/app/runs/[runId]/page.jsx +79 -10
- flowyml/ui/frontend/src/app/runs/page.jsx +82 -424
- flowyml/ui/frontend/src/app/settings/page.jsx +1 -0
- flowyml/ui/frontend/src/app/tokens/page.jsx +62 -16
- flowyml/ui/frontend/src/components/AssetDetailsPanel.jsx +373 -0
- flowyml/ui/frontend/src/components/AssetLineageGraph.jsx +291 -0
- flowyml/ui/frontend/src/components/AssetStatsDashboard.jsx +302 -0
- flowyml/ui/frontend/src/components/AssetTreeHierarchy.jsx +477 -0
- flowyml/ui/frontend/src/components/ExperimentDetailsPanel.jsx +227 -0
- flowyml/ui/frontend/src/components/NavigationTree.jsx +401 -0
- flowyml/ui/frontend/src/components/PipelineDetailsPanel.jsx +239 -0
- flowyml/ui/frontend/src/components/PipelineGraph.jsx +67 -3
- flowyml/ui/frontend/src/components/ProjectSelector.jsx +115 -0
- flowyml/ui/frontend/src/components/RunDetailsPanel.jsx +298 -0
- flowyml/ui/frontend/src/components/header/Header.jsx +48 -1
- flowyml/ui/frontend/src/components/plugins/ZenMLIntegration.jsx +106 -0
- flowyml/ui/frontend/src/components/sidebar/Sidebar.jsx +52 -26
- flowyml/ui/frontend/src/components/ui/DataView.jsx +35 -17
- flowyml/ui/frontend/src/components/ui/ErrorBoundary.jsx +118 -0
- flowyml/ui/frontend/src/contexts/ProjectContext.jsx +2 -2
- flowyml/ui/frontend/src/contexts/ToastContext.jsx +116 -0
- flowyml/ui/frontend/src/layouts/MainLayout.jsx +5 -1
- flowyml/ui/frontend/src/router/index.jsx +4 -0
- flowyml/ui/frontend/src/utils/date.js +10 -0
- flowyml/ui/frontend/src/utils/downloads.js +11 -0
- flowyml/utils/config.py +6 -0
- flowyml/utils/stack_config.py +45 -3
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/METADATA +44 -4
- flowyml-1.4.0.dist-info/RECORD +200 -0
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/licenses/LICENSE +1 -1
- flowyml/ui/frontend/dist/assets/index-DFNQnrUj.js +0 -448
- flowyml/ui/frontend/dist/assets/index-pWI271rZ.css +0 -1
- flowyml-1.2.0.dist-info/RECORD +0 -159
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/WHEEL +0 -0
- {flowyml-1.2.0.dist-info → flowyml-1.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -1,45 +1,511 @@
|
|
|
1
|
-
from fastapi import APIRouter, HTTPException
|
|
1
|
+
from fastapi import APIRouter, HTTPException, UploadFile, File
|
|
2
|
+
from fastapi.responses import FileResponse
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
2
4
|
from flowyml.storage.metadata import SQLiteMetadataStore
|
|
5
|
+
from flowyml.core.project import ProjectManager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from flowyml.ui.backend.dependencies import get_store
|
|
8
|
+
import shutil
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
3
11
|
|
|
4
12
|
router = APIRouter()
|
|
5
13
|
|
|
6
14
|
|
|
7
|
-
def
|
|
8
|
-
|
|
15
|
+
def _save_file_sync(src, dst):
|
|
16
|
+
with open(dst, "wb") as buffer:
|
|
17
|
+
shutil.copyfileobj(src, buffer)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _iter_metadata_stores():
|
|
21
|
+
stores = [(None, SQLiteMetadataStore())]
|
|
22
|
+
try:
|
|
23
|
+
manager = ProjectManager()
|
|
24
|
+
for project_meta in manager.list_projects():
|
|
25
|
+
name = project_meta.get("name")
|
|
26
|
+
if not name:
|
|
27
|
+
continue
|
|
28
|
+
project = manager.get_project(name)
|
|
29
|
+
if project:
|
|
30
|
+
stores.append((name, project.metadata_store))
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
return stores
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _dedupe_assets(assets):
|
|
37
|
+
dedup = {}
|
|
38
|
+
for asset, project_name in assets:
|
|
39
|
+
artifact_id = asset.get("artifact_id") or f"{project_name}:{len(dedup)}"
|
|
40
|
+
if artifact_id in dedup:
|
|
41
|
+
continue
|
|
42
|
+
entry = dict(asset)
|
|
43
|
+
if project_name and not entry.get("project"):
|
|
44
|
+
entry["project"] = project_name
|
|
45
|
+
dedup[artifact_id] = entry
|
|
46
|
+
return list(dedup.values())
|
|
9
47
|
|
|
10
48
|
|
|
11
49
|
@router.get("/")
|
|
12
50
|
async def list_assets(limit: int = 50, asset_type: str = None, run_id: str = None, project: str = None):
|
|
13
51
|
"""List all assets, optionally filtered by project."""
|
|
52
|
+
try:
|
|
53
|
+
combined = []
|
|
54
|
+
for project_name, store in _iter_metadata_stores():
|
|
55
|
+
if project and project_name and project != project_name:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
filters = {}
|
|
59
|
+
if asset_type:
|
|
60
|
+
filters["type"] = asset_type
|
|
61
|
+
if run_id:
|
|
62
|
+
filters["run_id"] = run_id
|
|
63
|
+
|
|
64
|
+
assets = store.list_assets(limit=limit, **filters)
|
|
65
|
+
for asset in assets:
|
|
66
|
+
combined.append((asset, project_name))
|
|
67
|
+
|
|
68
|
+
assets = _dedupe_assets(combined)
|
|
69
|
+
|
|
70
|
+
if project:
|
|
71
|
+
assets = [a for a in assets if a.get("project") == project]
|
|
72
|
+
|
|
73
|
+
assets = assets[:limit]
|
|
74
|
+
|
|
75
|
+
return {"assets": assets}
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return {"assets": [], "error": str(e)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AssetCreate(BaseModel):
|
|
81
|
+
artifact_id: str
|
|
82
|
+
name: str
|
|
83
|
+
asset_type: str = Field(..., alias="type")
|
|
84
|
+
run_id: str
|
|
85
|
+
step: str
|
|
86
|
+
project: str | None = None
|
|
87
|
+
metadata: dict = {}
|
|
88
|
+
value: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.post("/")
|
|
92
|
+
async def create_asset(asset: AssetCreate):
|
|
93
|
+
"""Create or update an asset metadata."""
|
|
94
|
+
try:
|
|
95
|
+
store = get_store()
|
|
96
|
+
|
|
97
|
+
# Prepare metadata
|
|
98
|
+
metadata = asset.metadata.copy()
|
|
99
|
+
metadata.update(
|
|
100
|
+
{
|
|
101
|
+
"name": asset.name,
|
|
102
|
+
"type": asset.asset_type,
|
|
103
|
+
"run_id": asset.run_id,
|
|
104
|
+
"step": asset.step,
|
|
105
|
+
"project": asset.project,
|
|
106
|
+
"value": asset.value,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
store.save_artifact(asset.artifact_id, metadata)
|
|
111
|
+
return {"status": "success", "artifact_id": asset.artifact_id}
|
|
112
|
+
except Exception as e:
|
|
113
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.post("/{artifact_id}/upload")
|
|
117
|
+
async def upload_asset_content(artifact_id: str, file: UploadFile = File(...)):
|
|
118
|
+
"""Upload content for an artifact."""
|
|
14
119
|
try:
|
|
15
120
|
store = get_store()
|
|
16
121
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
122
|
+
# Get existing metadata to find path or create a new one
|
|
123
|
+
existing = store.load_artifact(artifact_id)
|
|
124
|
+
|
|
125
|
+
if not existing:
|
|
126
|
+
raise HTTPException(status_code=404, detail="Artifact metadata not found. Create metadata first.")
|
|
127
|
+
|
|
128
|
+
# Determine storage path
|
|
129
|
+
# We use the LocalArtifactStore logic here since the backend is running locally relative to itself
|
|
130
|
+
from flowyml.storage.artifacts import LocalArtifactStore
|
|
131
|
+
from flowyml.utils.config import get_config
|
|
132
|
+
|
|
133
|
+
config = get_config()
|
|
134
|
+
artifact_store = LocalArtifactStore(base_path=config.artifacts_dir)
|
|
135
|
+
|
|
136
|
+
# Construct a path if not present
|
|
137
|
+
if not existing.get("path"):
|
|
138
|
+
# Create a path structure: project/run_id/artifact_id/filename
|
|
139
|
+
project = existing.get("project", "default")
|
|
140
|
+
run_id = existing.get("run_id", "unknown")
|
|
141
|
+
filename = file.filename or "content"
|
|
142
|
+
rel_path = f"{project}/{run_id}/{artifact_id}/{filename}"
|
|
143
|
+
else:
|
|
144
|
+
rel_path = existing.get("path")
|
|
145
|
+
# If path is absolute, make it relative to artifacts dir if possible, or just use it
|
|
146
|
+
# But LocalArtifactStore expects relative paths usually, or handles absolute ones
|
|
147
|
+
|
|
148
|
+
# Save the file
|
|
149
|
+
full_path = artifact_store.base_path / rel_path
|
|
150
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
loop = asyncio.get_running_loop()
|
|
153
|
+
await loop.run_in_executor(None, _save_file_sync, file.file, full_path)
|
|
154
|
+
|
|
155
|
+
# Update metadata with path
|
|
156
|
+
existing["path"] = str(rel_path)
|
|
157
|
+
store.save_artifact(artifact_id, existing)
|
|
158
|
+
|
|
159
|
+
return {"status": "success", "path": str(rel_path)}
|
|
160
|
+
|
|
161
|
+
except HTTPException:
|
|
162
|
+
raise
|
|
163
|
+
except Exception as e:
|
|
164
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.delete("/{artifact_id}")
|
|
168
|
+
async def delete_asset(artifact_id: str):
|
|
169
|
+
"""Delete an asset and its file."""
|
|
170
|
+
try:
|
|
171
|
+
store = get_store()
|
|
172
|
+
|
|
173
|
+
# Get metadata to find path
|
|
174
|
+
asset = store.load_artifact(artifact_id)
|
|
175
|
+
if not asset:
|
|
176
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
177
|
+
|
|
178
|
+
# Delete file if it exists locally (since backend is local to itself)
|
|
179
|
+
path = asset.get("path")
|
|
180
|
+
if path:
|
|
181
|
+
from flowyml.storage.artifacts import LocalArtifactStore
|
|
182
|
+
from flowyml.utils.config import get_config
|
|
183
|
+
|
|
184
|
+
config = get_config()
|
|
185
|
+
artifact_store = LocalArtifactStore(base_path=config.artifacts_dir)
|
|
186
|
+
|
|
187
|
+
with contextlib.suppress(Exception):
|
|
188
|
+
artifact_store.delete(path)
|
|
189
|
+
|
|
190
|
+
# Delete metadata
|
|
191
|
+
store.delete_artifact(artifact_id)
|
|
192
|
+
|
|
193
|
+
return {"status": "success", "artifact_id": artifact_id}
|
|
194
|
+
|
|
195
|
+
except HTTPException:
|
|
196
|
+
raise
|
|
197
|
+
except Exception as e:
|
|
198
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@router.get("/stats")
|
|
202
|
+
async def get_asset_stats(project: str | None = None):
|
|
203
|
+
"""Get statistics about assets for the dashboard."""
|
|
204
|
+
try:
|
|
205
|
+
combined_assets = []
|
|
206
|
+
for project_name, store in _iter_metadata_stores():
|
|
207
|
+
if project and project_name and project != project_name:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
assets = store.list_assets(limit=1000)
|
|
211
|
+
for asset in assets:
|
|
212
|
+
combined_assets.append((asset, project_name))
|
|
213
|
+
|
|
214
|
+
assets = _dedupe_assets(combined_assets)
|
|
215
|
+
|
|
216
|
+
if project:
|
|
217
|
+
assets = [a for a in assets if a.get("project") == project]
|
|
218
|
+
|
|
219
|
+
# Calculate statistics
|
|
220
|
+
total_assets = len(assets)
|
|
221
|
+
|
|
222
|
+
# Count by type
|
|
223
|
+
type_counts = {}
|
|
224
|
+
for asset in assets:
|
|
225
|
+
asset_type = asset.get("type", "unknown")
|
|
226
|
+
type_counts[asset_type] = type_counts.get(asset_type, 0) + 1
|
|
227
|
+
|
|
228
|
+
# Calculate total storage (if size info available)
|
|
229
|
+
total_storage = sum(asset.get("size_bytes", 0) for asset in assets)
|
|
230
|
+
|
|
231
|
+
# Get recent assets (last 10)
|
|
232
|
+
sorted_assets = sorted(
|
|
233
|
+
assets,
|
|
234
|
+
key=lambda x: x.get("created_at", ""),
|
|
235
|
+
reverse=True,
|
|
236
|
+
)
|
|
237
|
+
recent_assets = sorted_assets[:10]
|
|
238
|
+
|
|
239
|
+
# Count by project
|
|
240
|
+
project_counts = {}
|
|
241
|
+
for asset in assets:
|
|
242
|
+
proj = asset.get("project", "default")
|
|
243
|
+
project_counts[proj] = project_counts.get(proj, 0) + 1
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
"total_assets": total_assets,
|
|
247
|
+
"by_type": type_counts,
|
|
248
|
+
"total_storage_bytes": total_storage,
|
|
249
|
+
"recent_assets": recent_assets,
|
|
250
|
+
"by_project": project_counts,
|
|
251
|
+
}
|
|
252
|
+
except Exception as e:
|
|
253
|
+
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@router.get("/search")
|
|
257
|
+
async def search_assets(q: str, limit: int = 50, project: str | None = None):
|
|
258
|
+
"""Search assets by name or properties."""
|
|
259
|
+
try:
|
|
260
|
+
combined_assets = []
|
|
261
|
+
for project_name, store in _iter_metadata_stores():
|
|
262
|
+
if project and project_name and project != project_name:
|
|
263
|
+
continue
|
|
23
264
|
|
|
24
|
-
|
|
265
|
+
assets = store.list_assets(limit=1000)
|
|
266
|
+
for asset in assets:
|
|
267
|
+
combined_assets.append((asset, project_name))
|
|
268
|
+
|
|
269
|
+
assets = _dedupe_assets(combined_assets)
|
|
25
270
|
|
|
26
|
-
# Filter by project if specified
|
|
27
271
|
if project:
|
|
28
|
-
|
|
272
|
+
assets = [a for a in assets if a.get("project") == project]
|
|
273
|
+
|
|
274
|
+
# Simple fuzzy search in name, type, and step
|
|
275
|
+
query_lower = q.lower()
|
|
276
|
+
matching_assets = []
|
|
277
|
+
|
|
278
|
+
for asset in assets:
|
|
279
|
+
name = asset.get("name", "").lower()
|
|
280
|
+
asset_type = asset.get("type", "").lower()
|
|
281
|
+
step = asset.get("step", "").lower()
|
|
282
|
+
|
|
283
|
+
# Check if query matches any field
|
|
284
|
+
if query_lower in name or query_lower in asset_type or query_lower in step:
|
|
285
|
+
matching_assets.append(asset)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
"assets": matching_assets[:limit],
|
|
289
|
+
"total": len(matching_assets),
|
|
290
|
+
"query": q,
|
|
291
|
+
}
|
|
292
|
+
except Exception as e:
|
|
293
|
+
raise HTTPException(status_code=500, detail=f"Failed to search: {str(e)}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@router.get("/lineage")
|
|
297
|
+
async def get_asset_lineage(
|
|
298
|
+
asset_id: str | None = None,
|
|
299
|
+
project: str | None = None,
|
|
300
|
+
depth: int = 3,
|
|
301
|
+
):
|
|
302
|
+
"""
|
|
303
|
+
Get lineage graph for artifacts.
|
|
304
|
+
|
|
305
|
+
Returns nodes (artifacts, runs, pipelines) and edges (relationships).
|
|
306
|
+
Can be scoped to a specific asset or all assets in a project.
|
|
307
|
+
"""
|
|
308
|
+
try:
|
|
309
|
+
store = get_store()
|
|
310
|
+
nodes = []
|
|
311
|
+
edges = []
|
|
312
|
+
visited_artifacts = set()
|
|
313
|
+
visited_runs = set()
|
|
314
|
+
|
|
315
|
+
# Get starting artifacts
|
|
316
|
+
if asset_id:
|
|
317
|
+
artifact = store.load_artifact(asset_id)
|
|
318
|
+
if not artifact:
|
|
319
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
320
|
+
starting_artifacts = [artifact]
|
|
321
|
+
elif project:
|
|
322
|
+
# Get all artifacts for project
|
|
29
323
|
all_runs = store.list_runs(limit=1000)
|
|
30
324
|
project_run_ids = {r.get("run_id") for r in all_runs if r.get("project") == project}
|
|
31
|
-
|
|
325
|
+
all_artifacts = store.list_assets(limit=1000)
|
|
326
|
+
starting_artifacts = [a for a in all_artifacts if a.get("run_id") in project_run_ids]
|
|
327
|
+
else:
|
|
328
|
+
# Get recent artifacts
|
|
329
|
+
starting_artifacts = store.list_assets(limit=50)
|
|
32
330
|
|
|
33
|
-
|
|
331
|
+
# Build graph
|
|
332
|
+
for artifact in starting_artifacts:
|
|
333
|
+
artifact_id = artifact.get("artifact_id")
|
|
334
|
+
if not artifact_id or artifact_id in visited_artifacts:
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
visited_artifacts.add(artifact_id)
|
|
338
|
+
|
|
339
|
+
# Add artifact node
|
|
340
|
+
nodes.append(
|
|
341
|
+
{
|
|
342
|
+
"id": artifact_id,
|
|
343
|
+
"type": "artifact",
|
|
344
|
+
"label": artifact.get("name", artifact_id),
|
|
345
|
+
"artifact_type": artifact.get("type", "unknown"),
|
|
346
|
+
"metadata": {
|
|
347
|
+
"created_at": artifact.get("created_at"),
|
|
348
|
+
"properties": artifact.get("properties", {}),
|
|
349
|
+
"size": artifact.get("size"),
|
|
350
|
+
"run_id": artifact.get("run_id"),
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Add relationship to run
|
|
356
|
+
run_id = artifact.get("run_id")
|
|
357
|
+
if run_id and run_id not in visited_runs:
|
|
358
|
+
visited_runs.add(run_id)
|
|
359
|
+
run = store.load_run(run_id)
|
|
360
|
+
|
|
361
|
+
if run:
|
|
362
|
+
# Add run node
|
|
363
|
+
nodes.append(
|
|
364
|
+
{
|
|
365
|
+
"id": run_id,
|
|
366
|
+
"type": "run",
|
|
367
|
+
"label": run.get("run_name", run_id[:8]),
|
|
368
|
+
"metadata": {
|
|
369
|
+
"pipeline_name": run.get("pipeline_name"),
|
|
370
|
+
"status": run.get("status"),
|
|
371
|
+
"project": run.get("project"),
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# Add edge: run produces artifact
|
|
377
|
+
edges.append(
|
|
378
|
+
{
|
|
379
|
+
"id": f"{run_id}->{artifact_id}",
|
|
380
|
+
"source": run_id,
|
|
381
|
+
"target": artifact_id,
|
|
382
|
+
"type": "produces",
|
|
383
|
+
"label": artifact.get("step", ""),
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Find input artifacts (from DAG/steps metadata)
|
|
388
|
+
metadata = run.get("metadata", {})
|
|
389
|
+
# dag = metadata.get("dag", {})
|
|
390
|
+
steps = metadata.get("steps", {})
|
|
391
|
+
|
|
392
|
+
# Get step that produced this artifact
|
|
393
|
+
artifact_step = artifact.get("step")
|
|
394
|
+
if artifact_step and artifact_step in steps:
|
|
395
|
+
step_info = steps.get(artifact_step, {})
|
|
396
|
+
inputs = step_info.get("inputs", [])
|
|
397
|
+
|
|
398
|
+
# Find artifacts that were inputs to this step
|
|
399
|
+
for input_name in inputs:
|
|
400
|
+
# Search for artifact with this name from the same run
|
|
401
|
+
input_artifacts = [
|
|
402
|
+
a for a in all_artifacts if a.get("name") == input_name and a.get("run_id") == run_id
|
|
403
|
+
]
|
|
404
|
+
for input_artifact in input_artifacts:
|
|
405
|
+
input_id = input_artifact.get("artifact_id")
|
|
406
|
+
if input_id and input_id not in visited_artifacts:
|
|
407
|
+
visited_artifacts.add(input_id)
|
|
408
|
+
nodes.append(
|
|
409
|
+
{
|
|
410
|
+
"id": input_id,
|
|
411
|
+
"type": "artifact",
|
|
412
|
+
"label": input_artifact.get("name", input_id),
|
|
413
|
+
"artifact_type": input_artifact.get("type", "unknown"),
|
|
414
|
+
"metadata": {
|
|
415
|
+
"created_at": input_artifact.get("created_at"),
|
|
416
|
+
"properties": input_artifact.get("properties", {}),
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Add edge: input artifact consumed by run
|
|
422
|
+
edges.append(
|
|
423
|
+
{
|
|
424
|
+
"id": f"{input_id}->{run_id}",
|
|
425
|
+
"source": input_id,
|
|
426
|
+
"target": run_id,
|
|
427
|
+
"type": "consumes",
|
|
428
|
+
"label": artifact_step,
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
"nodes": nodes,
|
|
434
|
+
"edges": edges,
|
|
435
|
+
"metadata": {
|
|
436
|
+
"total_artifacts": len([n for n in nodes if n["type"] == "artifact"]),
|
|
437
|
+
"total_runs": len([n for n in nodes if n["type"] == "run"]),
|
|
438
|
+
"depth_used": depth,
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
except HTTPException:
|
|
443
|
+
raise
|
|
34
444
|
except Exception as e:
|
|
35
|
-
|
|
445
|
+
raise HTTPException(status_code=500, detail=f"Failed to build lineage: {str(e)}")
|
|
36
446
|
|
|
37
447
|
|
|
38
448
|
@router.get("/{artifact_id}")
|
|
39
449
|
async def get_asset(artifact_id: str):
|
|
40
450
|
"""Get details for a specific asset."""
|
|
41
|
-
|
|
42
|
-
asset = store.load_artifact(artifact_id)
|
|
451
|
+
asset = _find_asset(artifact_id)
|
|
43
452
|
if not asset:
|
|
44
453
|
raise HTTPException(status_code=404, detail="Asset not found")
|
|
45
454
|
return asset
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@router.get("/{artifact_id}/download")
|
|
458
|
+
async def download_asset(artifact_id: str):
|
|
459
|
+
"""Download the artifact file referenced by metadata."""
|
|
460
|
+
asset, _ = _find_asset_with_store(artifact_id)
|
|
461
|
+
if not asset:
|
|
462
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
463
|
+
|
|
464
|
+
artifact_path = asset.get("path")
|
|
465
|
+
if not artifact_path:
|
|
466
|
+
raise HTTPException(status_code=404, detail="Artifact path not available")
|
|
467
|
+
|
|
468
|
+
file_path = Path(artifact_path)
|
|
469
|
+
if not file_path.exists():
|
|
470
|
+
raise HTTPException(status_code=404, detail="Artifact file not found on disk")
|
|
471
|
+
|
|
472
|
+
return FileResponse(
|
|
473
|
+
path=file_path,
|
|
474
|
+
filename=file_path.name,
|
|
475
|
+
media_type="application/octet-stream",
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class ProjectUpdate(BaseModel):
|
|
480
|
+
project_name: str
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@router.put("/{artifact_id}/project")
|
|
484
|
+
async def update_asset_project(artifact_id: str, update: ProjectUpdate):
|
|
485
|
+
"""Update the project for an asset."""
|
|
486
|
+
try:
|
|
487
|
+
_, store = _find_asset_with_store(artifact_id)
|
|
488
|
+
if not store:
|
|
489
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
490
|
+
|
|
491
|
+
store.update_artifact_project(artifact_id, update.project_name)
|
|
492
|
+
return {"status": "success", "project": update.project_name}
|
|
493
|
+
except HTTPException:
|
|
494
|
+
raise
|
|
495
|
+
except Exception as e:
|
|
496
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _find_asset(artifact_id: str):
|
|
500
|
+
asset, _ = _find_asset_with_store(artifact_id)
|
|
501
|
+
return asset
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _find_asset_with_store(artifact_id: str):
|
|
505
|
+
for project_name, store in _iter_metadata_stores():
|
|
506
|
+
asset = store.load_artifact(artifact_id)
|
|
507
|
+
if asset:
|
|
508
|
+
if project_name and not asset.get("project"):
|
|
509
|
+
asset["project"] = project_name
|
|
510
|
+
return asset, store
|
|
511
|
+
return None, None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from typing import Any
|
|
4
|
+
from flowyml.monitoring.alerts import alert_manager, AlertLevel
|
|
5
|
+
|
|
6
|
+
router = APIRouter()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClientError(BaseModel):
|
|
10
|
+
message: str
|
|
11
|
+
stack: str | None = None
|
|
12
|
+
component_stack: str | None = None
|
|
13
|
+
url: str | None = None
|
|
14
|
+
user_agent: str | None = None
|
|
15
|
+
additional_info: dict[str, Any] | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("/errors")
|
|
19
|
+
async def report_client_error(error: ClientError, request: Request):
|
|
20
|
+
"""Report an error from the frontend client."""
|
|
21
|
+
|
|
22
|
+
# Construct a detailed message
|
|
23
|
+
details = f"Client Error: {error.message}\n"
|
|
24
|
+
if error.url:
|
|
25
|
+
details += f"URL: {error.url}\n"
|
|
26
|
+
if error.user_agent:
|
|
27
|
+
details += f"User Agent: {error.user_agent}\n"
|
|
28
|
+
if error.component_stack:
|
|
29
|
+
details += f"Component Stack:\n{error.component_stack}\n"
|
|
30
|
+
if error.stack:
|
|
31
|
+
details += f"Stack Trace:\n{error.stack}\n"
|
|
32
|
+
|
|
33
|
+
# Send alert
|
|
34
|
+
alert_manager.send_alert(
|
|
35
|
+
title=f"Frontend Error: {error.message[:50]}...",
|
|
36
|
+
message=details,
|
|
37
|
+
level=AlertLevel.ERROR,
|
|
38
|
+
metadata={
|
|
39
|
+
"source": "frontend",
|
|
40
|
+
"url": error.url,
|
|
41
|
+
"user_agent": error.user_agent,
|
|
42
|
+
"additional_info": error.additional_info,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return {"status": "recorded"}
|
|
@@ -27,6 +27,7 @@ class PipelineExecutionRequest(BaseModel):
|
|
|
27
27
|
parameters: dict[str, Any] = {}
|
|
28
28
|
project: str | None = None
|
|
29
29
|
dry_run: bool = False # If True, validate but don't execute
|
|
30
|
+
retry_count: int = 0 # Number of retries on failure (0-5)
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class TokenRequest(BaseModel):
|
|
@@ -92,13 +93,23 @@ async def execute_pipeline(
|
|
|
92
93
|
|
|
93
94
|
pipeline = getattr(module, request.pipeline_name)
|
|
94
95
|
|
|
95
|
-
# Execute the pipeline
|
|
96
|
-
|
|
96
|
+
# Execute the pipeline with retry policy if specified
|
|
97
|
+
run_kwargs = request.parameters.copy()
|
|
98
|
+
|
|
99
|
+
if request.retry_count > 0:
|
|
100
|
+
from flowyml.core.retry import OrchestratorRetryPolicy
|
|
101
|
+
|
|
102
|
+
run_kwargs["retry_policy"] = OrchestratorRetryPolicy(
|
|
103
|
+
max_retries=min(request.retry_count, 5), # Cap at 5
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
result = pipeline.run(**run_kwargs)
|
|
97
107
|
|
|
98
108
|
return {
|
|
99
109
|
"status": "completed",
|
|
100
110
|
"run_id": result.run_id if hasattr(result, "run_id") else None,
|
|
101
111
|
"pipeline": request.pipeline_name,
|
|
112
|
+
"retry_count": request.retry_count,
|
|
102
113
|
"message": "Pipeline executed successfully",
|
|
103
114
|
}
|
|
104
115
|
|